@runuai/host 0.9.5 → 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.
@@ -73,9 +73,22 @@ import type {
73
73
  ChannelHuman,
74
74
  HostEvent,
75
75
  } from "../src/protocol";
76
+ import {
77
+ MAX_AGENT_ID_CHARS,
78
+ MAX_SECRETARY_DISPATCH_ID_CHARS,
79
+ SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
80
+ } from "../src/protocol";
76
81
 
77
82
  export type HostEventSubscriber = (event: HostEvent) => void;
78
83
 
84
+ // Rolling upgrade fence: v0 Secretary runners were launched under the
85
+ // communicator tool-denial profile and may survive a host restart. Durable
86
+ // attach is otherwise keyed only by task+agent, so give the new normal-tools
87
+ // policy an explicit generation and replace any legacy null-key runner once.
88
+ const SECRETARY_SESSION_ATTACH_KEY = "secretary-normal-tools-v1";
89
+ const RESTRICTED_SECRETARY_SESSION_ATTACH_KEY =
90
+ "secretary-communicator-v1";
91
+
79
92
  // ---------------------------------------------------------------------------
80
93
  // Channel — one task's live conversation.
81
94
  // ---------------------------------------------------------------------------
@@ -154,6 +167,11 @@ interface Channel {
154
167
  /** Agents with a delivered turn that has not reached a terminal event yet.
155
168
  * Unlike openTurns, this covers thinking/tool/permission time before text. */
156
169
  activeTurns: Map<string, number>;
170
+ /** Crew replies waiting for the designated Secretary's current turn
171
+ * boundary. Adapters do not share one mid-turn input contract, so only this
172
+ * cross-lane role is serialized at the host boundary; open mode keeps its
173
+ * legacy immediate-concurrent delivery behavior. */
174
+ pendingPrompts: Map<string, string[]>;
157
175
  /** Agents whose current turn was interrupted (ESC) — their next
158
176
  * turn_complete is flagged `aborted` so the cloud DISCARDS the buffered
159
177
  * half-turn instead of delivering it to @-mentioned peers. */
@@ -330,14 +348,12 @@ export class Orchestrator {
330
348
  // -- channel lifecycle ----------------------------------------------------
331
349
 
332
350
  registerChannelSpec(spec: ChannelEnsureInput): void {
333
- // Host-side fail closed. The cloud validates this too, but the adapter's
334
- // advertised communicator guarantee must not depend on every caller
335
- // spelling the id correctly: an unmatched designation would otherwise
336
- // make `executionProfileFor` return undefined and spawn a full-access
337
- // process in a task explicitly marked Secretary.
351
+ // Host-side fail closed. The cloud validates this too, but routing must not
352
+ // depend on every caller spelling the id correctly: an unmatched
353
+ // designation would otherwise create Secretary mode with no secretary.
338
354
  if (!hasValidSecretarySelection(spec)) {
339
355
  throw new Error(
340
- "secretary mode requires exactly one roster agent matching secretaryAgentId",
356
+ "secretary mode requires typed-dispatch negotiation and exactly one roster agent matching secretaryAgentId",
341
357
  );
342
358
  }
343
359
  const channel = this.channels.get(spec.taskId);
@@ -352,7 +368,7 @@ export class Orchestrator {
352
368
  // Open→Secretary rewrite cannot be applied as ordinary roster refresh:
353
369
  // the designated agent may already be a durable full-access process.
354
370
  // Changing these fields without recycling that exact session would
355
- // advertise a communicator boundary the running process does not have.
371
+ // advertise a different role/routing boundary than the running process.
356
372
  throw new Error(
357
373
  "a live channel cannot change secretary mode or designation",
358
374
  );
@@ -531,6 +547,7 @@ export class Orchestrator {
531
547
  closed: false,
532
548
  openTurns: new Set(),
533
549
  activeTurns: new Map(),
550
+ pendingPrompts: new Map(),
534
551
  interrupted: new Set(),
535
552
  respawns: new Map(),
536
553
  respawnLastAt: new Map(),
@@ -616,6 +633,19 @@ export class Orchestrator {
616
633
  if (!this.isActiveChannel(channel)) return false;
617
634
  await this.reconcileSessions(channel);
618
635
  }
636
+ // The initial-start promise stays memoized after a healthy boot. A later
637
+ // fatal exit can therefore leave the designated Secretary missing even
638
+ // when reconciliation could not recreate it (for example because CLI
639
+ // materialization now fails). Secretary mode must report that degraded
640
+ // state rather than reuse the old `true` forever.
641
+ if (
642
+ ready &&
643
+ channel.mode === "secretary" &&
644
+ (!channel.secretaryAgentId ||
645
+ !channel.sessions.has(channel.secretaryAgentId))
646
+ ) {
647
+ return false;
648
+ }
619
649
  return ready;
620
650
  }
621
651
 
@@ -1213,7 +1243,21 @@ export class Orchestrator {
1213
1243
 
1214
1244
  const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
1215
1245
  const cliSecret = loadTaskCliSecret(channel.taskId);
1216
- writeAgentCli(channel.taskId, roster, apiUrl);
1246
+ const cliWritten = writeAgentCli(
1247
+ channel.taskId,
1248
+ roster,
1249
+ apiUrl,
1250
+ channel.mode === "secretary",
1251
+ );
1252
+ if (
1253
+ this.hasPromptLevelSecretary(channel) &&
1254
+ (!cliWritten || !task.ownerUserId || !apiUrl || !cliSecret)
1255
+ ) {
1256
+ console.warn(
1257
+ `[orchestrator] ${channel.taskId}: prompt-level Secretary CLI/token materialization failed`,
1258
+ );
1259
+ return;
1260
+ }
1217
1261
 
1218
1262
  for (const agent of missing) {
1219
1263
  if (!this.isActiveChannel(channel)) return;
@@ -1229,6 +1273,10 @@ export class Orchestrator {
1229
1273
  containerName: channel.containerName,
1230
1274
  systemPreamble: channel.preambles.get(agent.id) ?? "",
1231
1275
  executionProfile: this.executionProfileFor(channel, agent.id),
1276
+ attachCompatibilityKey: this.attachCompatibilityKeyFor(
1277
+ channel,
1278
+ agent.id,
1279
+ ),
1232
1280
  agentEnv: this.accountAgentEnv(
1233
1281
  channel,
1234
1282
  agent,
@@ -1238,11 +1286,13 @@ export class Orchestrator {
1238
1286
  task.ownerUserId,
1239
1287
  apiUrl,
1240
1288
  cliSecret,
1289
+ this.isPromptLevelSecretary(channel, agent.id),
1241
1290
  ),
1242
1291
  ),
1243
1292
  },
1244
1293
  );
1245
1294
  if (!session) return;
1295
+ this.startNextPendingPrompt(channel, agent.id);
1246
1296
  }
1247
1297
  } finally {
1248
1298
  for (const agent of missing) channel.spawning.delete(agent.id);
@@ -1252,19 +1302,49 @@ export class Orchestrator {
1252
1302
  }
1253
1303
  }
1254
1304
 
1255
- /**
1256
- * The browser stores only the task-level secretary identity. Derive the
1257
- * restricted adapter profile here so a roster entry can never self-assert a
1258
- * weaker/stronger execution policy through its JSON payload.
1259
- */
1305
+ /** Reserved seam for a future explicit restricted-role control. */
1260
1306
  private executionProfileFor(
1307
+ _channel: Channel,
1308
+ _agentId: string,
1309
+ ): "communicator" | undefined {
1310
+ // Secretary is an organisational role, not a sandbox. The communicator
1311
+ // profile remains implemented by adapters for a future explicit opt-in,
1312
+ // but no task gets tool denial merely by selecting Secretary mode.
1313
+ return undefined;
1314
+ }
1315
+
1316
+ private attachCompatibilityKeyFor(
1261
1317
  channel: Channel,
1262
1318
  agentId: string,
1263
- ): "communicator" | undefined {
1264
- return channel.mode === "secretary" &&
1265
- channel.secretaryAgentId === agentId
1266
- ? "communicator"
1267
- : undefined;
1319
+ ): string | undefined {
1320
+ if (!this.isSecretary(channel, agentId)) return undefined;
1321
+ // Key the durable runner to the policy actually passed to the adapter.
1322
+ // The restricted profile is dormant today, but giving it the normal-tools
1323
+ // generation would make a future opt-out able to attach a runner launched
1324
+ // under tool denial.
1325
+ return this.executionProfileFor(channel, agentId) === "communicator"
1326
+ ? RESTRICTED_SECRETARY_SESSION_ATTACH_KEY
1327
+ : SECRETARY_SESSION_ATTACH_KEY;
1328
+ }
1329
+
1330
+ private isSecretary(channel: Channel, agentId: string): boolean {
1331
+ return (
1332
+ channel.mode === "secretary" && channel.secretaryAgentId === agentId
1333
+ );
1334
+ }
1335
+
1336
+ private isPromptLevelSecretary(channel: Channel, agentId: string): boolean {
1337
+ return (
1338
+ this.isSecretary(channel, agentId) &&
1339
+ this.executionProfileFor(channel, agentId) === undefined
1340
+ );
1341
+ }
1342
+
1343
+ private hasPromptLevelSecretary(channel: Channel): boolean {
1344
+ return Boolean(
1345
+ channel.secretaryAgentId &&
1346
+ this.isPromptLevelSecretary(channel, channel.secretaryAgentId),
1347
+ );
1268
1348
  }
1269
1349
 
1270
1350
  /**
@@ -1448,7 +1528,21 @@ export class Orchestrator {
1448
1528
  // are actually enforced. Best-effort, host-side.
1449
1529
  const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
1450
1530
  const cliSecret = loadTaskCliSecret(channel.taskId);
1451
- writeAgentCli(channel.taskId, channel.roster, apiUrl);
1531
+ const cliWritten = writeAgentCli(
1532
+ channel.taskId,
1533
+ channel.roster,
1534
+ apiUrl,
1535
+ channel.mode === "secretary",
1536
+ );
1537
+ if (
1538
+ this.hasPromptLevelSecretary(channel) &&
1539
+ (!cliWritten || !task.ownerUserId || !apiUrl || !cliSecret)
1540
+ ) {
1541
+ console.warn(
1542
+ `[orchestrator] ${channel.taskId}: prompt-level Secretary CLI/token materialization failed`,
1543
+ );
1544
+ return false;
1545
+ }
1452
1546
 
1453
1547
  for (const agent of initialRoster) {
1454
1548
  if (!this.isActiveChannel(channel)) return false;
@@ -1468,6 +1562,10 @@ export class Orchestrator {
1468
1562
  containerName: channel.containerName,
1469
1563
  systemPreamble: channel.preambles.get(agent.id) ?? "",
1470
1564
  executionProfile: this.executionProfileFor(channel, agent.id),
1565
+ attachCompatibilityKey: this.attachCompatibilityKeyFor(
1566
+ channel,
1567
+ agent.id,
1568
+ ),
1471
1569
  agentEnv: this.accountAgentEnv(
1472
1570
  channel,
1473
1571
  agent,
@@ -1477,11 +1575,13 @@ export class Orchestrator {
1477
1575
  task.ownerUserId,
1478
1576
  apiUrl,
1479
1577
  cliSecret,
1578
+ this.isPromptLevelSecretary(channel, agent.id),
1480
1579
  ),
1481
1580
  ),
1482
1581
  },
1483
1582
  );
1484
1583
  if (!session) return false;
1584
+ this.startNextPendingPrompt(channel, agent.id);
1485
1585
  } finally {
1486
1586
  channel.spawning.delete(agent.id);
1487
1587
  }
@@ -1503,9 +1603,9 @@ export class Orchestrator {
1503
1603
  * calls this so agents auto-start the moment the task is running,
1504
1604
  * without the human having to send a message first.
1505
1605
  */
1506
- async ensureStarted(taskId: string): Promise<void> {
1606
+ async ensureStarted(taskId: string): Promise<boolean> {
1507
1607
  const channel = await this.getOrCreateChannel(taskId);
1508
- if (channel) await this.ensureSessions(channel);
1608
+ return channel ? this.ensureSessions(channel) : false;
1509
1609
  }
1510
1610
 
1511
1611
  private incrementActiveTurns(channel: Channel, agentId: string): void {
@@ -1534,6 +1634,77 @@ export class Orchestrator {
1534
1634
  return remaining;
1535
1635
  }
1536
1636
 
1637
+ /** Retire turn-local state owned by a runner generation before replacement. */
1638
+ private retireRunnerGeneration(channel: Channel, agentId: string): void {
1639
+ channel.activeTurns.delete(agentId);
1640
+ channel.openTurns.delete(agentId);
1641
+ channel.interrupted.delete(agentId);
1642
+ }
1643
+
1644
+ private startPrompt(
1645
+ channel: Channel,
1646
+ agentId: string,
1647
+ session: AgentSession,
1648
+ prompt: string,
1649
+ ): void {
1650
+ // ADR-076: this is the prompt actually in flight. A queued follow-up must
1651
+ // not replace it or account rotation would replay the wrong turn.
1652
+ channel.lastPrompt.set(agentId, prompt);
1653
+ this.incrementActiveTurns(channel, agentId);
1654
+ void session.send(prompt).catch((err: unknown) => {
1655
+ if (channel.sessions.get(agentId) === session) {
1656
+ const remaining = this.decrementActiveTurns(channel, agentId);
1657
+ if (
1658
+ remaining === 0 &&
1659
+ channel.lastPrompt.get(agentId) === prompt
1660
+ ) {
1661
+ channel.lastPrompt.delete(agentId);
1662
+ }
1663
+ this.startNextPendingPrompt(channel, agentId);
1664
+ }
1665
+ console.warn(
1666
+ `[orchestrator] ${channel.taskId}/${agentId}: send failed: ${
1667
+ err instanceof Error ? err.message : String(err)
1668
+ }`,
1669
+ );
1670
+ });
1671
+ }
1672
+
1673
+ /** Start at most one queued prompt after the previous turn is terminal. */
1674
+ private startNextPendingPrompt(channel: Channel, agentId: string): boolean {
1675
+ if (
1676
+ !this.isActiveChannel(channel) ||
1677
+ !this.isSecretary(channel, agentId) ||
1678
+ (channel.activeTurns.get(agentId) ?? 0) > 0
1679
+ ) {
1680
+ return false;
1681
+ }
1682
+ const queue = channel.pendingPrompts.get(agentId);
1683
+ const session = channel.sessions.get(agentId);
1684
+ if (!queue || queue.length === 0 || !session) return false;
1685
+ const prompt = queue.shift()!;
1686
+ if (queue.length === 0) channel.pendingPrompts.delete(agentId);
1687
+ this.startPrompt(channel, agentId, session, prompt);
1688
+ return true;
1689
+ }
1690
+
1691
+ /** A dead Secretary session must not strand already-accepted crew replies. */
1692
+ private reconcilePendingSecretary(channel: Channel, agentId: string): void {
1693
+ if (
1694
+ !this.isSecretary(channel, agentId) ||
1695
+ (channel.pendingPrompts.get(agentId)?.length ?? 0) === 0
1696
+ ) {
1697
+ return;
1698
+ }
1699
+ void this.reconcileSessions(channel).catch((err: unknown) => {
1700
+ console.warn(
1701
+ `[orchestrator] ${channel.taskId}/${agentId}: pending-prompt reconcile failed: ${
1702
+ err instanceof Error ? err.message : String(err)
1703
+ }`,
1704
+ );
1705
+ });
1706
+ }
1707
+
1537
1708
  // -- inbound: a human (or routed peer) message ----------------------------
1538
1709
 
1539
1710
  /**
@@ -1563,20 +1734,23 @@ export class Orchestrator {
1563
1734
  if (!session) return { ok: false, error: `no such agent: ${agentId}` };
1564
1735
 
1565
1736
  const prompt = rewriteAttachmentRefs(text);
1566
- // ADR-076: remember the in-flight prompt so an account rotation on a
1567
- // rate-limit can re-deliver it to the fresh session.
1568
- channel.lastPrompt.set(agentId, prompt);
1569
- this.incrementActiveTurns(channel, agentId);
1570
- void session.send(prompt).catch((err: unknown) => {
1571
- if (channel.sessions.get(agentId) === session) {
1572
- this.decrementActiveTurns(channel, agentId);
1737
+ if (this.isSecretary(channel, agentId)) {
1738
+ const queue = channel.pendingPrompts.get(agentId) ?? [];
1739
+ if ((channel.activeTurns.get(agentId) ?? 0) > 0 || queue.length > 0) {
1740
+ queue.push(prompt);
1741
+ channel.pendingPrompts.set(agentId, queue);
1742
+ // A replacement session may have bound after the queue was observed
1743
+ // but before this delivery. Preserve FIFO by starting the oldest item.
1744
+ this.startNextPendingPrompt(channel, agentId);
1745
+ } else {
1746
+ this.startPrompt(channel, agentId, session, prompt);
1573
1747
  }
1574
- console.warn(
1575
- `[orchestrator] ${channel.taskId}/${agentId}: send failed: ${
1576
- err instanceof Error ? err.message : String(err)
1577
- }`,
1578
- );
1579
- });
1748
+ } else {
1749
+ // Preserve the legacy open-room contract: adapters receive concurrent
1750
+ // deliveries immediately. Only the designated Secretary needs the hard
1751
+ // turn boundary because crew replies can arrive during its dispatch turn.
1752
+ this.startPrompt(channel, agentId, session, prompt);
1753
+ }
1580
1754
  return { ok: true };
1581
1755
  }
1582
1756
 
@@ -1663,6 +1837,24 @@ export class Orchestrator {
1663
1837
  });
1664
1838
  break;
1665
1839
  }
1840
+ case "dispatch": {
1841
+ this.markActiveTurn(channel, agentId);
1842
+ this.emitHost({
1843
+ kind: "agent.dispatch",
1844
+ taskId: channel.taskId,
1845
+ agentId,
1846
+ // Optional trace metadata must never make the action undeliverable.
1847
+ // Provider tool ids have no useful product-level length guarantee;
1848
+ // omit an oversized one while preserving the dispatch itself.
1849
+ ...(event.dispatchId.length > 0 &&
1850
+ event.dispatchId.length <= MAX_SECRETARY_DISPATCH_ID_CHARS
1851
+ ? { dispatchId: event.dispatchId }
1852
+ : {}),
1853
+ recipients: event.recipients,
1854
+ instruction: event.instruction,
1855
+ });
1856
+ break;
1857
+ }
1666
1858
  case "permission_request": {
1667
1859
  this.markActiveTurn(channel, agentId);
1668
1860
  this.emitHost({
@@ -1692,9 +1884,7 @@ export class Orchestrator {
1692
1884
  case "error": {
1693
1885
  // The turn died with the session — drop its turn-state flags so a
1694
1886
  // respawned session starts clean.
1695
- channel.activeTurns.delete(agentId);
1696
- channel.openTurns.delete(agentId);
1697
- channel.interrupted.delete(agentId);
1887
+ this.retireRunnerGeneration(channel, agentId);
1698
1888
  // Claude under load (especially Docker Desktop macOS) occasionally
1699
1889
  // unlinks ~/.claude.json mid-write during atomic config rewrites,
1700
1890
  // and a concurrent claude spawn lands during the gap and exits 0
@@ -1725,9 +1915,14 @@ export class Orchestrator {
1725
1915
  re.test(event.message),
1726
1916
  );
1727
1917
  if (isClaude && configMissing) {
1918
+ // This recovery path deliberately starts the next queued turn rather
1919
+ // than replaying the failed one. Do not leave it looking in-flight to
1920
+ // a later account refresh.
1921
+ channel.lastPrompt.delete(agentId);
1728
1922
  await this.recoverClaudeAgent(channel, agentId);
1729
1923
  break;
1730
1924
  }
1925
+ channel.lastPrompt.delete(agentId);
1731
1926
  this.emitHost({
1732
1927
  kind: "agent.exit",
1733
1928
  taskId: channel.taskId,
@@ -1749,6 +1944,7 @@ export class Orchestrator {
1749
1944
  channel.sessions.delete(agentId);
1750
1945
  channel.browserStaleSessions.delete(agentId);
1751
1946
  channel.browserPendingStaleSessions.delete(agentId);
1947
+ this.reconcilePendingSecretary(channel, agentId);
1752
1948
  break;
1753
1949
  }
1754
1950
  case "turn_complete": {
@@ -1769,10 +1965,14 @@ export class Orchestrator {
1769
1965
  aborted,
1770
1966
  usage: event.usage,
1771
1967
  });
1968
+ if (remainingTurns === 0) {
1969
+ channel.lastPrompt.delete(agentId);
1970
+ this.startNextPendingPrompt(channel, agentId);
1971
+ }
1772
1972
  // A config repair never interrupts a turn. The stale id remains queued
1773
1973
  // until this exact boundary, then reconcile closes and replaces it.
1774
1974
  if (
1775
- remainingTurns === 0 &&
1975
+ (channel.activeTurns.get(agentId) ?? 0) === 0 &&
1776
1976
  channel.browserStaleSessions.has(agentId)
1777
1977
  ) {
1778
1978
  void this.reconcileSessions(channel).catch((err: unknown) => {
@@ -1788,14 +1988,14 @@ export class Orchestrator {
1788
1988
  case "exit":
1789
1989
  // Same zombie hazard as the error path — a session whose process
1790
1990
  // ended (even cleanly) can never carry another turn.
1791
- channel.activeTurns.delete(agentId);
1792
- channel.openTurns.delete(agentId);
1793
- channel.interrupted.delete(agentId);
1991
+ this.retireRunnerGeneration(channel, agentId);
1992
+ channel.lastPrompt.delete(agentId);
1794
1993
  channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
1795
1994
  channel.respawnLastAt.set(agentId, Date.now());
1796
1995
  channel.sessions.delete(agentId);
1797
1996
  channel.browserStaleSessions.delete(agentId);
1798
1997
  channel.browserPendingStaleSessions.delete(agentId);
1998
+ this.reconcilePendingSecretary(channel, agentId);
1799
1999
  break;
1800
2000
  }
1801
2001
  }
@@ -1845,6 +2045,7 @@ export class Orchestrator {
1845
2045
  channel.sessions.delete(agentId);
1846
2046
  channel.browserStaleSessions.delete(agentId);
1847
2047
  channel.browserPendingStaleSessions.delete(agentId);
2048
+ this.retireRunnerGeneration(channel, agentId);
1848
2049
  if (old) {
1849
2050
  try {
1850
2051
  await old.close();
@@ -1866,6 +2067,7 @@ export class Orchestrator {
1866
2067
  task.ownerUserId,
1867
2068
  apiUrl,
1868
2069
  cliSecret,
2070
+ this.isPromptLevelSecretary(channel, agent.id),
1869
2071
  );
1870
2072
  const boundAccountId = channel.accountByAgent.get(agentId);
1871
2073
  const boundAccount = boundAccountId
@@ -1879,11 +2081,16 @@ export class Orchestrator {
1879
2081
  containerName: channel.containerName,
1880
2082
  systemPreamble: channel.preambles.get(agentId) ?? "",
1881
2083
  executionProfile: this.executionProfileFor(channel, agentId),
2084
+ attachCompatibilityKey: this.attachCompatibilityKeyFor(
2085
+ channel,
2086
+ agentId,
2087
+ ),
1882
2088
  agentEnv: boundAccount
1883
2089
  ? { ...base, ...boundAccount.execEnv }
1884
2090
  : this.accountAgentEnv(channel, agent, base),
1885
2091
  });
1886
2092
  if (!session) return;
2093
+ this.startNextPendingPrompt(channel, agentId);
1887
2094
 
1888
2095
  const message = restored
1889
2096
  ? `${agentId} restarted — config file was missing in the container, restored from the host.`
@@ -1896,6 +2103,9 @@ export class Orchestrator {
1896
2103
  });
1897
2104
  } finally {
1898
2105
  channel.spawning.delete(agentId);
2106
+ if (!channel.sessions.has(agentId)) {
2107
+ this.reconcilePendingSecretary(channel, agentId);
2108
+ }
1899
2109
  // A concurrent config writer can mark the newly-bound recovery stale
1900
2110
  // while this spawn slot is still held. Reconciliation skips spawning
1901
2111
  // ids by design, so release must explicitly give that mark another turn.
@@ -1957,6 +2167,7 @@ export class Orchestrator {
1957
2167
  channel.sessions.delete(agentId);
1958
2168
  channel.browserStaleSessions.delete(agentId);
1959
2169
  channel.browserPendingStaleSessions.delete(agentId);
2170
+ this.retireRunnerGeneration(channel, agentId);
1960
2171
  if (old) {
1961
2172
  try {
1962
2173
  await old.close();
@@ -1985,6 +2196,7 @@ export class Orchestrator {
1985
2196
  task.ownerUserId,
1986
2197
  apiUrl,
1987
2198
  cliSecret,
2199
+ this.isPromptLevelSecretary(channel, agent.id),
1988
2200
  );
1989
2201
  channel.accountByAgent.set(agentId, next.id);
1990
2202
  noteEngineAccountUsed(next.id);
@@ -1998,6 +2210,10 @@ export class Orchestrator {
1998
2210
  containerName: channel.containerName,
1999
2211
  systemPreamble: channel.preambles.get(agentId) ?? "",
2000
2212
  executionProfile: this.executionProfileFor(channel, agentId),
2213
+ attachCompatibilityKey: this.attachCompatibilityKeyFor(
2214
+ channel,
2215
+ agentId,
2216
+ ),
2001
2217
  agentEnv: { ...base, ...next.execEnv },
2002
2218
  },
2003
2219
  );
@@ -2011,20 +2227,15 @@ export class Orchestrator {
2011
2227
  // Re-deliver the in-flight prompt so the interrupted turn resumes.
2012
2228
  const prompt = channel.lastPrompt.get(agentId);
2013
2229
  if (prompt) {
2014
- this.incrementActiveTurns(channel, agentId);
2015
- void replacement.send(prompt).catch((err: unknown) => {
2016
- if (channel.sessions.get(agentId) === replacement) {
2017
- this.decrementActiveTurns(channel, agentId);
2018
- }
2019
- console.warn(
2020
- `[orchestrator] ${channel.taskId}/${agentId}: rotated send failed: ${
2021
- err instanceof Error ? err.message : String(err)
2022
- }`,
2023
- );
2024
- });
2230
+ this.startPrompt(channel, agentId, replacement, prompt);
2231
+ } else {
2232
+ this.startNextPendingPrompt(channel, agentId);
2025
2233
  }
2026
2234
  } finally {
2027
2235
  channel.spawning.delete(agentId);
2236
+ if (!replacement) {
2237
+ this.reconcilePendingSecretary(channel, agentId);
2238
+ }
2028
2239
  }
2029
2240
 
2030
2241
  // Browser/config setup may have marked other live generations stale. Run
@@ -2073,6 +2284,10 @@ export class Orchestrator {
2073
2284
  channel.sessions.delete(agentId);
2074
2285
  channel.browserStaleSessions.delete(agentId);
2075
2286
  channel.browserPendingStaleSessions.delete(agentId);
2287
+ // The old runner can no longer complete any of its turns. The prompt
2288
+ // itself remains in lastPrompt for the replacement to replay, but its
2289
+ // counters belong to the retired generation and must start over.
2290
+ this.retireRunnerGeneration(channel, agentId);
2076
2291
  if (old) {
2077
2292
  try {
2078
2293
  await old.close();
@@ -2099,6 +2314,7 @@ export class Orchestrator {
2099
2314
  task.ownerUserId,
2100
2315
  apiUrl,
2101
2316
  cliSecret,
2317
+ this.isPromptLevelSecretary(channel, agent.id),
2102
2318
  );
2103
2319
  channel.accountByAgent.set(agentId, account.id);
2104
2320
  noteEngineAccountUsed(account.id);
@@ -2108,6 +2324,11 @@ export class Orchestrator {
2108
2324
  agent,
2109
2325
  containerName: channel.containerName,
2110
2326
  systemPreamble: channel.preambles.get(agentId) ?? "",
2327
+ executionProfile: this.executionProfileFor(channel, agentId),
2328
+ attachCompatibilityKey: this.attachCompatibilityKeyFor(
2329
+ channel,
2330
+ agentId,
2331
+ ),
2111
2332
  agentEnv: { ...base, ...account.execEnv },
2112
2333
  });
2113
2334
  if (!replacement) return true;
@@ -2117,20 +2338,15 @@ export class Orchestrator {
2117
2338
  // Re-deliver the in-flight prompt so the interrupted turn resumes.
2118
2339
  const prompt = channel.lastPrompt.get(agentId);
2119
2340
  if (prompt) {
2120
- this.incrementActiveTurns(channel, agentId);
2121
- void replacement.send(prompt).catch((err: unknown) => {
2122
- if (channel.sessions.get(agentId) === replacement) {
2123
- this.decrementActiveTurns(channel, agentId);
2124
- }
2125
- console.warn(
2126
- `[orchestrator] ${channel.taskId}/${agentId}: respawn send failed: ${
2127
- err instanceof Error ? err.message : String(err)
2128
- }`,
2129
- );
2130
- });
2341
+ this.startPrompt(channel, agentId, replacement, prompt);
2342
+ } else {
2343
+ this.startNextPendingPrompt(channel, agentId);
2131
2344
  }
2132
2345
  } finally {
2133
2346
  channel.spawning.delete(agentId);
2347
+ if (!replacement) {
2348
+ this.reconcilePendingSecretary(channel, agentId);
2349
+ }
2134
2350
  }
2135
2351
 
2136
2352
  if (
@@ -2252,6 +2468,7 @@ export class Orchestrator {
2252
2468
  ch.browserStaleSessions.clear();
2253
2469
  ch.browserPendingStaleSessions.clear();
2254
2470
  ch.activeTurns.clear();
2471
+ ch.pendingPrompts.clear();
2255
2472
  ch.openTurns.clear();
2256
2473
  ch.interrupted.clear();
2257
2474
  ch.reconcileAgain = false;
@@ -2367,10 +2584,22 @@ export class Orchestrator {
2367
2584
 
2368
2585
  /** Exact host-wire role validation; never trims or canonicalises agent ids. */
2369
2586
  export function hasValidSecretarySelection(
2370
- spec: Pick<ChannelEnsureInput, "mode" | "secretaryAgentId" | "agents">,
2587
+ spec: Pick<
2588
+ ChannelEnsureInput,
2589
+ "mode" | "secretaryAgentId" | "secretaryDispatchProtocol" | "agents"
2590
+ >,
2371
2591
  ): boolean {
2372
2592
  if (spec.mode !== "secretary") return true;
2593
+ if (
2594
+ spec.secretaryDispatchProtocol !==
2595
+ SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE
2596
+ ) {
2597
+ return false;
2598
+ }
2373
2599
  if (!spec.secretaryAgentId) return false;
2600
+ if (spec.agents.some((agent) => agent.id.length > MAX_AGENT_ID_CHARS)) {
2601
+ return false;
2602
+ }
2374
2603
  return (
2375
2604
  spec.agents.filter((agent) => agent.id === spec.secretaryAgentId).length ===
2376
2605
  1
@@ -2628,6 +2857,14 @@ export function buildSystemPreamble(
2628
2857
  );
2629
2858
  const isSecretary =
2630
2859
  mode === "secretary" && secretaryAgentId === agent.id;
2860
+ const dispatchActionBrief = [
2861
+ "When crew work is needed, run the Secretary CLI action:",
2862
+ `\`node ${CONTAINER_CLI_PATH} dispatch @agent [@agent…] \"instruction\"\``,
2863
+ "using exact crew @ids and one concrete, self-contained instruction.",
2864
+ "The command prints a refusal if Uai did not accept the dispatch. Your",
2865
+ "prose stays frontstage: it is not delivered to the crew or written to their",
2866
+ "normal transcript, and naming an agent there does NOT wake them.",
2867
+ ];
2631
2868
  const transcriptBrief =
2632
2869
  mode !== "secretary"
2633
2870
  ? [
@@ -2660,15 +2897,17 @@ export function buildSystemPreamble(
2660
2897
  "## Secretary role",
2661
2898
  "",
2662
2899
  "You are the channel's sole human-facing communicator. Answer the human",
2663
- "directly when the transcripts and read-only inspection are sufficient.",
2664
- "When crew work is needed, address each recipient by @id with a concrete",
2665
- "instruction; Uai delivers that instruction backstage. Synthesize the",
2900
+ "directly when the transcripts and your own inspection are sufficient.",
2901
+ ...dispatchActionBrief,
2902
+ "Synthesize the",
2666
2903
  "crew's replies for the human instead of forwarding a pile of raw updates.",
2667
2904
  "",
2668
- "Your communicator execution profile is enforced by the engine: you can",
2669
- "read and search the workspace, but you cannot edit files, run shell",
2670
- "commands, write git state, deploy, or invoke arbitrary extensions. Do not",
2671
- "claim that you performed a mutation; dispatch it to a crew agent instead.",
2905
+ "Your Secretary role is prompt-guided, not tool-restricted. You have the",
2906
+ "same normal tools, approved MCP connections, model choice, and persona",
2907
+ "permissions as any other task agent. Your default job is to communicate,",
2908
+ "coordinate, and dispatch implementation work rather than doing it yourself.",
2909
+ "If the human explicitly asks YOU to make a change, you may use those tools",
2910
+ "and do it directly; otherwise hand mutations to a crew agent.",
2672
2911
  "",
2673
2912
  ]
2674
2913
  : [];
@@ -2676,7 +2915,9 @@ export function buildSystemPreamble(
2676
2915
  ? [
2677
2916
  "When a human message names one or more crew agents, those names are",
2678
2917
  "routing hints for you — the crew has NOT been notified yet. Decide what",
2679
- "work is actually needed, then dispatch a concrete @id instruction to each",
2918
+ `work is actually needed, then run \`node ${CONTAINER_CLI_PATH} dispatch …\``,
2919
+ "with a concrete",
2920
+ "instruction for each",
2680
2921
  "crew member you need. Do not merely say that someone else will answer.",
2681
2922
  ]
2682
2923
  : [
@@ -2723,14 +2964,15 @@ export function buildSystemPreamble(
2723
2964
  ? [
2724
2965
  "## Workspace layout",
2725
2966
  "",
2726
- `Your read-only workspace root is \`${workspacePath}\`. It contains one`,
2967
+ `Your shell starts in \`${workspacePath}\`. It contains one`,
2727
2968
  "project worktree per repository:",
2728
2969
  "",
2729
2970
  ...projectLines,
2730
2971
  "",
2731
2972
  `Those worktrees are on \`${taskBranch}\`. Inspect files when that helps`,
2732
- "you answer or scope a dispatch. Your profile cannot edit, commit, push,",
2733
- "or open a PR; send that work to a crew agent.",
2973
+ "you answer or scope a dispatch.",
2974
+ "By default, send edits, commits, pushes, and PR work to a crew agent.",
2975
+ "You may do that work yourself when the human explicitly asks you to.",
2734
2976
  "",
2735
2977
  "The `.uai/` directory is Uai scaffolding. Read the two transcript files",
2736
2978
  "there as described above; do not treat the rest as project content.",
@@ -2759,13 +3001,65 @@ export function buildSystemPreamble(
2759
3001
  "treat it as ignored, even though git may show it as untracked.",
2760
3002
  "",
2761
3003
  ];
3004
+ const channelRoutingBrief = isSecretary
3005
+ ? [
3006
+ "You are the designated Secretary in a uai task chat channel shared with",
3007
+ "the human and the crew. Your normal prose is frontstage: it is not",
3008
+ "delivered to the crew or shown in their normal feed/transcript. Naming",
3009
+ "or @-mentioning a crew agent in prose does NOT wake them.",
3010
+ "The only way to hand crew work off is the structured `dispatch` action.",
3011
+ ]
3012
+ : [
3013
+ "You are one agent in a uai task chat channel, shared with the human",
3014
+ "and the other agents. To hand work to or ask another agent, mention",
3015
+ "it by id at the start of a line — e.g. `@codex please review the",
3016
+ "diff`. uai routes that message into that agent's input.",
3017
+ ];
3018
+ const collaborationBrief = isSecretary
3019
+ ? [
3020
+ "Collaborate through deliberate dispatches. Each dispatch wakes a crew",
3021
+ "agent and costs a turn, so make every instruction concrete, self-contained,",
3022
+ "and necessary. Never dispatch greetings, thanks, or acknowledgments.",
3023
+ ]
3024
+ : [
3025
+ "An agent only receives a message when it is explicitly @-mentioned",
3026
+ "(or addressed by the human) — so always @-mention the agent (or @you)",
3027
+ "you mean. There is NO `peer` command and no shared tmux session;",
3028
+ "hand-offs are just @-mentions in your replies.",
3029
+ "",
3030
+ "Collaborate with your peers — divide up the work, review each other's",
3031
+ "changes, share concrete ideas, and debate approach decisions by",
3032
+ "@-mentioning them. That is how the team gets things done, and you",
3033
+ "should do it freely whenever it moves the work forward. But mentioning",
3034
+ "a peer WAKES it and costs a turn, so make each one count: a message to",
3035
+ "a peer should ADVANCE the work — a real proposal, a question you need",
3036
+ "answered, a hand-off, or a review with specific findings. Do NOT",
3037
+ "@-mention a peer just to greet, thank, agree, acknowledge, or say",
3038
+ "you're ready — content-free replies wake them for nothing and spiral",
3039
+ "into endless back-and-forth. If you have nothing substantive to add,",
3040
+ "don't @-mention back. And when there's no active task yet (intros, or",
3041
+ "you're waiting on the human), answer briefly and then wait — you don't",
3042
+ "need to @-mention anyone (including @you); they can see the channel.",
3043
+ ];
3044
+ const channelConventionsBrief = isSecretary
3045
+ ? [
3046
+ "Your prose always goes to the human-facing lane; answer plainly without",
3047
+ "re-mentioning the asker. Crew @mentions in prose are labels only. Use",
3048
+ "the structured dispatch action when a crew agent must actually act.",
3049
+ "You may occasionally receive a `[channel check-in]` asking you to catch",
3050
+ "up on the channel.",
3051
+ ]
3052
+ : [
3053
+ "Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
3054
+ "uai hands it back to whoever prompted you — so when you're ANSWERING,",
3055
+ "just answer plainly; you don't need to re-mention the asker. Mention",
3056
+ "someone only to bring them in or hand work off. (2) You may occasionally",
3057
+ "receive a `[channel check-in]` asking you to catch up on the channel.",
3058
+ ];
2762
3059
  const comms = [
2763
3060
  "## uai task channel",
2764
3061
  "",
2765
- "You are one agent in a uai task chat channel, shared with the human",
2766
- "and the other agents. To hand work to or ask another agent, mention",
2767
- "it by id at the start of a line — e.g. `@codex please review the",
2768
- "diff`. uai routes that message into that agent's input.",
3062
+ ...channelRoutingBrief,
2769
3063
  "",
2770
3064
  `**You are @${agent.id}** — your name in this channel is **${agent.label}**.`,
2771
3065
  `Introduce and refer to yourself as ${agent.label}, not as a generic`,
@@ -2775,35 +3069,14 @@ export function buildSystemPreamble(
2775
3069
  "",
2776
3070
  ...humanIntro,
2777
3071
  "",
2778
- "An agent only receives a message when it is explicitly @-mentioned",
2779
- "(or addressed by the human) — so always @-mention the agent (or @you)",
2780
- "you mean. There is NO `peer` command and no shared tmux session;",
2781
- "hand-offs are just @-mentions in your replies.",
2782
- "",
2783
- "Collaborate with your peers — divide up the work, review each other's",
2784
- "changes, share concrete ideas, and debate approach decisions by",
2785
- "@-mentioning them. That is how the team gets things done, and you",
2786
- "should do it freely whenever it moves the work forward. But mentioning",
2787
- "a peer WAKES it and costs a turn, so make each one count: a message to",
2788
- "a peer should ADVANCE the work — a real proposal, a question you need",
2789
- "answered, a hand-off, or a review with specific findings. Do NOT",
2790
- "@-mention a peer just to greet, thank, agree, acknowledge, or say",
2791
- "you're ready — content-free replies wake them for nothing and spiral",
2792
- "into endless back-and-forth. If you have nothing substantive to add,",
2793
- "don't @-mention back. And when there's no active task yet (intros, or",
2794
- "you're waiting on the human), answer briefly and then wait — you don't",
2795
- "need to @-mention anyone (including @you); they can see the channel.",
3072
+ ...collaborationBrief,
2796
3073
  "",
2797
3074
  ...groupMessageBrief,
2798
3075
  "",
2799
3076
  ...transcriptBrief,
2800
3077
  "",
2801
3078
  ...secretaryRoleBrief,
2802
- "Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
2803
- "uai hands it back to whoever prompted you — so when you're ANSWERING,",
2804
- "just answer plainly; you don't need to re-mention the asker. Mention",
2805
- "someone only to bring them in or hand work off. (2) You may occasionally",
2806
- "receive a `[channel check-in]` asking you to catch up on the channel.",
3079
+ ...channelConventionsBrief,
2807
3080
  ...checkInTranscriptBrief,
2808
3081
  "",
2809
3082
  ...handoffBrief,
@@ -2817,12 +3090,12 @@ export function buildSystemPreamble(
2817
3090
  ? [
2818
3091
  "## Shared files",
2819
3092
  "",
2820
- `Non-code files (${sharedFiles === "rw" && !isSecretary ? "read-write" : "READ-ONLY"} for this task):`,
3093
+ `Non-code files (${sharedFiles === "rw" ? "read-write" : "READ-ONLY"} for this task):`,
2821
3094
  "- `/workspace/files/org` — the org's shared files (logos, specs,",
2822
3095
  " datasets), visible to every task in the org on this host.",
2823
3096
  "- `/workspace/files/me` — the task owner's personal files, shared",
2824
3097
  " across their tasks on this host.",
2825
- ...(sharedFiles === "rw" && !isSecretary
3098
+ ...(sharedFiles === "rw"
2826
3099
  ? [
2827
3100
  "When producing artifacts for humans, write them here (use a",
2828
3101
  "subdirectory named after the task to avoid collisions).",
@@ -2849,8 +3122,7 @@ export function buildSystemPreamble(
2849
3122
  // ADR-047: package skills are native Claude Agent Skills installed into the
2850
3123
  // container's skills dir (Claude-only). List them so the agent knows they're
2851
3124
  // available even if headless auto-discovery is unreliable.
2852
- ...(!isSecretary &&
2853
- agent.kind === "claude" &&
3125
+ ...(agent.kind === "claude" &&
2854
3126
  (agent.skills ?? []).some((s) => s.type === "package")
2855
3127
  ? [
2856
3128
  "## Installed skills",
@@ -2864,7 +3136,7 @@ export function buildSystemPreamble(
2864
3136
  ]
2865
3137
  : []),
2866
3138
  // ADR-053: the in-container browser, when the project opted in.
2867
- ...(browserTesting && !isSecretary
3139
+ ...(browserTesting
2868
3140
  ? [
2869
3141
  "## Browser",
2870
3142
  "",
@@ -2886,12 +3158,10 @@ export function buildSystemPreamble(
2886
3158
  "",
2887
3159
  ]
2888
3160
  : []),
2889
- // ADR-048: tell agents with permissions about their `uai` CLI.
2890
- // The communicator profile deliberately exposes no Bash or arbitrary MCP
2891
- // tool, so even a persona carrying CLI permissions cannot invoke cli.mjs.
2892
- // Do not advertise unusable commands; a future typed communicator tool can
2893
- // surface the safe task/todo subset without widening this boundary.
2894
- ...((agent.permissions?.length ?? 0) > 0 && !isSecretary
3161
+ // ADR-048: tell every agent with persona-granted permissions about its
3162
+ // `uai` CLI. Secretary mode adds dispatch but does not invent any other
3163
+ // permission: an empty persona permission set remains empty.
3164
+ ...((agent.permissions?.length ?? 0) > 0
2895
3165
  ? [
2896
3166
  "## The uai CLI",
2897
3167
  "",
@@ -2981,19 +3251,15 @@ export function buildSystemPreamble(
2981
3251
  "",
2982
3252
  ]
2983
3253
  : []),
2984
- ...(!isSecretary
2985
- ? [
2986
- "## Commit policy",
2987
- "",
2988
- "When this task has an optional SSH signing key configured, Git signs",
2989
- "commits automatically; do not disable or override it. Do NOT add any",
2990
- "`Co-Authored-By:`",
2991
- "trailers to commit messages, and do NOT add 'Generated with …' or any",
2992
- "tool/agent attribution footer to commit messages or PR/issue bodies.",
2993
- "Write commit messages and PR descriptions plainly, as the author, with",
2994
- "no agent attribution.",
2995
- ]
2996
- : []),
3254
+ "## Commit policy",
3255
+ "",
3256
+ "When this task has an optional SSH signing key configured, Git signs",
3257
+ "commits automatically; do not disable or override it. Do NOT add any",
3258
+ "`Co-Authored-By:`",
3259
+ "trailers to commit messages, and do NOT add 'Generated with …' or any",
3260
+ "tool/agent attribution footer to commit messages or PR/issue bodies.",
3261
+ "Write commit messages and PR descriptions plainly, as the author, with",
3262
+ "no agent attribution.",
2997
3263
  ].join("\n");
2998
3264
 
2999
3265
  // Persona / mission layers, always-on so they apply to every turn:
@@ -3172,6 +3438,38 @@ async function dockerStart(containerName: string): Promise<boolean> {
3172
3438
  return true;
3173
3439
  }
3174
3440
 
3441
+ /**
3442
+ * Repair the shared node data root before any recovered-container init.
3443
+ * Older task-up versions created the OpenCode leaf as root and accidentally
3444
+ * left this parent root-owned, which prevented code-server from creating its
3445
+ * managed User profile. `install -d` creates or repairs the exact directory
3446
+ * without recursively changing unrelated application state beneath it.
3447
+ */
3448
+ async function repairNodeDataRoot(containerName: string): Promise<boolean> {
3449
+ const res = await dockerCli([
3450
+ "exec",
3451
+ "-u",
3452
+ "root",
3453
+ containerName,
3454
+ "/usr/bin/install",
3455
+ "-d",
3456
+ "-o",
3457
+ "node",
3458
+ "-g",
3459
+ "node",
3460
+ "-m",
3461
+ "0755",
3462
+ "/home/node/.local/share",
3463
+ ]);
3464
+ if (res.status !== 0) {
3465
+ console.error(
3466
+ `[orchestrator] recovery: ${containerName} could not repair /home/node/.local/share ownership: ${res.stderr.trim() || `docker exec exited ${String(res.status)}`}`,
3467
+ );
3468
+ return false;
3469
+ }
3470
+ return true;
3471
+ }
3472
+
3175
3473
  async function dockerPort(
3176
3474
  containerName: string,
3177
3475
  containerPort: number,
@@ -3372,6 +3670,9 @@ async function recoverOneTask(
3372
3670
  });
3373
3671
  return true;
3374
3672
  }
3673
+ // uai-init seeds code-server's profile beneath this shared data root. Heal
3674
+ // containers created by older hosts before running it as node.
3675
+ await repairNodeDataRoot(containerName);
3375
3676
  // Correctly-owned Codex creds BEFORE uai-init / any agent respawn: the boot
3376
3677
  // reinject sweep only targets containers already running, so a container
3377
3678
  // recovered here would otherwise keep whatever it held when it exited —