@runuai/host 0.8.45 → 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.
- package/README.md +5 -1
- package/bin/uai-host.mjs +120 -5
- package/lib/agents/claude.ts +86 -14
- package/lib/agents/factory.ts +9 -1
- package/lib/agents/mode.ts +57 -0
- package/lib/agents/registry.ts +27 -0
- package/lib/agents/types.ts +3 -0
- package/lib/mcp-connections.ts +63 -2
- package/lib/orchestrator.ts +250 -70
- package/lib/transcript.ts +17 -2
- package/package.json +3 -3
- package/src/cli.ts +7 -0
- package/src/index.ts +3 -2
- package/src/main.ts +12 -0
- package/src/protocol.ts +47 -0
- package/src/ui/server.ts +2 -0
- package/src/ui/types.ts +5 -0
package/README.md
CHANGED
|
@@ -18,7 +18,11 @@ One process does two things:
|
|
|
18
18
|
## Requirements
|
|
19
19
|
|
|
20
20
|
- **Docker** (running) — tasks are containers.
|
|
21
|
-
- **Node
|
|
21
|
+
- **Node 22, 24, 25 or 26; Node 24 LTS recommended.** Those are the majors the
|
|
22
|
+
pinned SQLite dependency ships a prebuilt binary for. Tested release majors
|
|
23
|
+
are 22, 24 and 26; Node 25 warns and continues when the binding loads.
|
|
24
|
+
Anything else — including Node 20, 21 and 23 — has to compile from source
|
|
25
|
+
and fails on a machine without a C++ toolchain.
|
|
22
26
|
|
|
23
27
|
## Install & enroll
|
|
24
28
|
|
package/bin/uai-host.mjs
CHANGED
|
@@ -5,10 +5,125 @@
|
|
|
5
5
|
* The host ships TypeScript source and runs it through tsx — no build step —
|
|
6
6
|
* so every runtime path (drizzle migrations, scripts/agent/*.sh, the
|
|
7
7
|
* images/standard build context) resolves off its source file exactly as in
|
|
8
|
-
* dev. This shim
|
|
9
|
-
*
|
|
8
|
+
* dev. This shim verifies that the native SQLite dependency can load,
|
|
9
|
+
* registers tsx's ESM loader, then hands off to src/cli.ts. argv passes
|
|
10
|
+
* straight through: src/cli.ts reads process.argv.slice(2).
|
|
10
11
|
*/
|
|
11
|
-
import { register } from "tsx/esm/api";
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// A builtin — importing it here loads nothing of the app and no tsx.
|
|
14
|
+
import { stripVTControlCharacters } from "node:util";
|
|
15
|
+
|
|
16
|
+
const minimumNodeMajor = 20;
|
|
17
|
+
const testedNodeMajors = new Set([22, 24, 26]);
|
|
18
|
+
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The commands that BOOT the agent, and so genuinely need the native module.
|
|
22
|
+
*
|
|
23
|
+
* Everything else — `logs`, `status`, `open`, `stop`, `uninstall`, `setup`,
|
|
24
|
+
* `--help` — only inspects or dismantles an install, and all of them were
|
|
25
|
+
* verified to run with the module unloadable. Failing them on a broken probe
|
|
26
|
+
* takes away the diagnostic path (`logs`) and the recovery path (`stop`,
|
|
27
|
+
* `uninstall`) from precisely the user the probe exists to help: they are told
|
|
28
|
+
* to reinstall, and cannot remove the wedged service first.
|
|
29
|
+
*
|
|
30
|
+
* `start`/`restart`/`install` don't open the database in THIS process — they
|
|
31
|
+
* hand off to launchd/systemd — but they all end in a running agent, so
|
|
32
|
+
* failing fast here beats a crash-looping service the user then has to chase
|
|
33
|
+
* through the log.
|
|
34
|
+
*/
|
|
35
|
+
const commandsNeedingNativeSqlite = new Set([
|
|
36
|
+
"run",
|
|
37
|
+
"start",
|
|
38
|
+
"restart",
|
|
39
|
+
"install",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Of those, the ones whose `--dry-run` prints a plan and boots nothing.
|
|
44
|
+
*
|
|
45
|
+
* `run` is deliberately absent: it ignores the flag and boots regardless, so
|
|
46
|
+
* exempting on the flag alone would wave through the one command that really
|
|
47
|
+
* does need the module.
|
|
48
|
+
*/
|
|
49
|
+
const dryRunnableCommands = new Set(["start", "restart", "install"]);
|
|
50
|
+
|
|
51
|
+
/** Whether this invocation will actually end in a running agent. */
|
|
52
|
+
function needsNativeSqlite(argv) {
|
|
53
|
+
const command = argv[2] ?? "";
|
|
54
|
+
if (!commandsNeedingNativeSqlite.has(command)) return false;
|
|
55
|
+
return !(dryRunnableCommands.has(command) && argv.includes("--dry-run"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function errorSummary(error) {
|
|
59
|
+
// Native loader errors can carry ANSI colour, which lands as escape noise in
|
|
60
|
+
// a log file or a non-TTY pipe. Strip it before picking the line to show.
|
|
61
|
+
const message = stripVTControlCharacters(
|
|
62
|
+
error instanceof Error ? error.message : String(error),
|
|
63
|
+
);
|
|
64
|
+
const firstLine = message
|
|
65
|
+
.split(/\r?\n/)
|
|
66
|
+
.map((line) => line.trim())
|
|
67
|
+
.find(Boolean);
|
|
68
|
+
if (!firstLine) return "unknown error";
|
|
69
|
+
return firstLine.length > 240 ? `${firstLine.slice(0, 237)}...` : firstLine;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function nativeRecoveryAdvice() {
|
|
73
|
+
return process.versions.electron
|
|
74
|
+
? "Update or reinstall the Uai Host desktop app and retry."
|
|
75
|
+
: "Reinstall @runuai/host; if that still fails, switch to Node.js 24 LTS and reinstall.";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function probeNativeSqlite() {
|
|
79
|
+
try {
|
|
80
|
+
const { default: Database } = await import("better-sqlite3");
|
|
81
|
+
const database = new Database(":memory:");
|
|
82
|
+
database.close();
|
|
83
|
+
return { ok: true };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { ok: false, error };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function main() {
|
|
90
|
+
if (!Number.isInteger(nodeMajor) || nodeMajor < minimumNodeMajor) {
|
|
91
|
+
console.error(
|
|
92
|
+
`uai-host requires Node.js 20 or newer; found v${process.versions.node}. Install Node.js 24 LTS and retry.`,
|
|
93
|
+
);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sqliteProbe = await probeNativeSqlite();
|
|
99
|
+
if (!sqliteProbe.ok) {
|
|
100
|
+
const detail =
|
|
101
|
+
`uai-host could not load its native SQLite module on Node.js v${process.versions.node}. ` +
|
|
102
|
+
`${nativeRecoveryAdvice()} Cause: ${errorSummary(sqliteProbe.error)}`;
|
|
103
|
+
if (needsNativeSqlite(process.argv)) {
|
|
104
|
+
console.error(detail);
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Still say it — a broken module IS why their service is down — but let
|
|
109
|
+
// the command through so they can read the log and uninstall.
|
|
110
|
+
console.warn(`${detail} Continuing — this command does not need it.`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Only when the probe actually succeeded — otherwise this contradicts the
|
|
114
|
+
// warning just printed, telling the user SQLite loaded right after saying it
|
|
115
|
+
// could not.
|
|
116
|
+
if (sqliteProbe.ok && !testedNodeMajors.has(nodeMajor)) {
|
|
117
|
+
console.warn(
|
|
118
|
+
`uai-host has not been tested on Node.js v${process.versions.node}; continuing because native SQLite loaded. Node.js 24 LTS is recommended.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Keep this import behind the native probe: the preflight must run in plain
|
|
123
|
+
// Node before tsx (and the TypeScript application graph) is loaded.
|
|
124
|
+
const { register } = await import("tsx/esm/api");
|
|
125
|
+
register();
|
|
126
|
+
await import(new URL("../src/cli.ts", import.meta.url).href);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await main();
|
package/lib/agents/claude.ts
CHANGED
|
@@ -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
|
|
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 = [
|
|
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
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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:
|
|
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 ({
|
|
401
|
-
|
|
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
|
});
|
package/lib/agents/factory.ts
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which agent adapters this host runs — `UAI_AGENTS`.
|
|
3
|
+
*
|
|
4
|
+
* REAL IS THE DEFAULT. It did not used to be: the check was
|
|
5
|
+
* `UAI_AGENTS === "real" ? real : mock`, so every value except that one
|
|
6
|
+
* literal — including unset — silently selected the echo mock. That was a
|
|
7
|
+
* sane default while the adapters were unproven and the only hosts were the
|
|
8
|
+
* author's, but it shipped to npm unchanged. A user installs `@runuai/host`,
|
|
9
|
+
* enrolls, starts a task, and the host looks perfectly healthy while every
|
|
10
|
+
* agent echoes canned text. Nothing in the logs or `uai-host status` said so.
|
|
11
|
+
*
|
|
12
|
+
* The desktop app never hit it because it hardcodes `UAI_AGENTS: "real"`
|
|
13
|
+
* (apps/host-desktop/src/agent-path.ts) — the npm path had no equivalent, so
|
|
14
|
+
* the bug was invisible to exactly the people who could reproduce it.
|
|
15
|
+
*
|
|
16
|
+
* So: a host runs real agents unless it is explicitly told not to. `mock` is
|
|
17
|
+
* the opt-in for tests and for dev boxes with no Docker, and an unrecognised
|
|
18
|
+
* value resolves to `real` with a warning rather than quietly downgrading —
|
|
19
|
+
* a typo must never cost someone a day of echoed output again.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type AgentMode = "real" | "mock";
|
|
23
|
+
|
|
24
|
+
/** Values that select the in-process echo mock. */
|
|
25
|
+
const MOCK_VALUES = new Set(["mock", "fake", "echo"]);
|
|
26
|
+
/** Values that select the real Claude/Codex adapters (the default). */
|
|
27
|
+
const REAL_VALUES = new Set(["real", "prod", "production"]);
|
|
28
|
+
|
|
29
|
+
export interface AgentModeResolution {
|
|
30
|
+
mode: AgentMode;
|
|
31
|
+
/** Set when the raw value was neither recognised nor empty. */
|
|
32
|
+
warning?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve `UAI_AGENTS` to a mode. Pure and case/whitespace insensitive so a
|
|
37
|
+
* stray `UAI_AGENTS=Mock ` from a hand-edited .env.local behaves as written.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveAgentMode(
|
|
40
|
+
raw: string | undefined = process.env.UAI_AGENTS,
|
|
41
|
+
): AgentModeResolution {
|
|
42
|
+
const value = (raw ?? "").trim().toLowerCase();
|
|
43
|
+
if (value === "") return { mode: "real" };
|
|
44
|
+
if (MOCK_VALUES.has(value)) return { mode: "mock" };
|
|
45
|
+
if (REAL_VALUES.has(value)) return { mode: "real" };
|
|
46
|
+
return {
|
|
47
|
+
mode: "real",
|
|
48
|
+
warning:
|
|
49
|
+
`unrecognised UAI_AGENTS=${JSON.stringify(raw)} — running REAL agents. ` +
|
|
50
|
+
`Use "mock" for the echo agent, or unset it.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The mode this process is running in. */
|
|
55
|
+
export function agentMode(): AgentMode {
|
|
56
|
+
return resolveAgentMode().mode;
|
|
57
|
+
}
|
package/lib/agents/registry.ts
CHANGED
|
@@ -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
|
}
|
package/lib/agents/types.ts
CHANGED
|
@@ -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>;
|
package/lib/mcp-connections.ts
CHANGED
|
@@ -98,6 +98,51 @@ async function mcpInitialize(
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Collapse whitespace and cap length — this text lands in the connect dialog. */
|
|
102
|
+
function oneLine(s: string, max = 300): string {
|
|
103
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
104
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The vendor's own explanation for a failed response, so the connect dialog
|
|
109
|
+
* can show it instead of a bare status code. Vendors bury it in one of a few
|
|
110
|
+
* shapes — RFC 6750's `error_description`, a bare `error`/`message`, or a
|
|
111
|
+
* GraphQL-style `errors[]` — so try each, then fall back to the raw body.
|
|
112
|
+
* Never throws: a missing or unparseable body just means no reason to add.
|
|
113
|
+
*/
|
|
114
|
+
async function failureReason(res: Response): Promise<string | null> {
|
|
115
|
+
const text = await res.text().catch(() => "");
|
|
116
|
+
if (!text.trim()) return null;
|
|
117
|
+
let parsed: unknown;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(text);
|
|
120
|
+
} catch {
|
|
121
|
+
return oneLine(text);
|
|
122
|
+
}
|
|
123
|
+
const body = (parsed ?? {}) as Record<string, unknown>;
|
|
124
|
+
const errors = body.errors;
|
|
125
|
+
const first = Array.isArray(errors)
|
|
126
|
+
? (errors[0] as Record<string, unknown> | undefined)
|
|
127
|
+
: undefined;
|
|
128
|
+
// `error` is a string in some payloads and an object wrapping `message` in
|
|
129
|
+
// others — New Relic alone returns both shapes plus `errors[]`, depending
|
|
130
|
+
// on which of its tiers answers. Prefer the descriptive fields; the bare
|
|
131
|
+
// `error` string ("unauthorized") is the last resort.
|
|
132
|
+
const nested =
|
|
133
|
+
typeof body.error === "object" && body.error !== null
|
|
134
|
+
? (body.error as Record<string, unknown>)
|
|
135
|
+
: undefined;
|
|
136
|
+
const reason = [
|
|
137
|
+
body.error_description,
|
|
138
|
+
body.message,
|
|
139
|
+
first?.message,
|
|
140
|
+
nested?.message,
|
|
141
|
+
body.error,
|
|
142
|
+
].find((c): c is string => typeof c === "string" && c.trim().length > 0);
|
|
143
|
+
return oneLine(reason ?? text);
|
|
144
|
+
}
|
|
145
|
+
|
|
101
146
|
// --- OAuth discovery (RFC 9728 → RFC 8414) -----------------------------------
|
|
102
147
|
|
|
103
148
|
/** Candidate well-known URLs, path-aware per spec, root fallback last. */
|
|
@@ -249,9 +294,25 @@ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
|
|
|
249
294
|
const header = { headerName: op.headerName, headerValue: op.headerValue };
|
|
250
295
|
const res = await mcpInitialize(op.url, header);
|
|
251
296
|
if (res.status === 401 || res.status === 403) {
|
|
252
|
-
|
|
297
|
+
// 401 and 403 send you to different fixes, so don't collapse them into
|
|
298
|
+
// one "rejected" message. 401 is a credential the server did not
|
|
299
|
+
// recognise — re-check the key. 403 is one it DID recognise and then
|
|
300
|
+
// refused: the key is fine, but its user lacks permission or the
|
|
301
|
+
// account is not entitled to this server. Telling someone to re-paste
|
|
302
|
+
// a working key is the expensive failure here.
|
|
303
|
+
const lead =
|
|
304
|
+
res.status === 401
|
|
305
|
+
? "the server rejected the token (401)"
|
|
306
|
+
: "the token was accepted but this server refused the request " +
|
|
307
|
+
"(403) — the key is likely valid but lacks permission for it";
|
|
308
|
+
const why = await failureReason(res);
|
|
309
|
+
throw new Error(why ? `${lead}: ${why}` : lead);
|
|
310
|
+
}
|
|
311
|
+
if (!res.ok) {
|
|
312
|
+
const why = await failureReason(res);
|
|
313
|
+
const lead = `unexpected response (${res.status})`;
|
|
314
|
+
throw new Error(why ? `${lead}: ${why}` : lead);
|
|
253
315
|
}
|
|
254
|
-
if (!res.ok) throw new Error(`unexpected response (${res.status})`);
|
|
255
316
|
db.insert(schema.mcpConnections)
|
|
256
317
|
.values({
|
|
257
318
|
id: op.connectionId,
|
package/lib/orchestrator.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { inArray } from "drizzle-orm";
|
|
|
25
25
|
import { getDb, schema } from "./db";
|
|
26
26
|
import { mockAgentFactory } from "./agents/mock";
|
|
27
27
|
import { realAgentFactory } from "./agents/factory";
|
|
28
|
+
import { resolveAgentMode } from "./agents/mode";
|
|
28
29
|
import {
|
|
29
30
|
type AgentEvent,
|
|
30
31
|
type AgentSession,
|
|
@@ -81,6 +82,9 @@ export type HostEventSubscriber = (event: HostEvent) => void;
|
|
|
81
82
|
interface Channel {
|
|
82
83
|
taskId: string;
|
|
83
84
|
roster: Roster;
|
|
85
|
+
/** ADR-083: host-derived role assignment for execution-profile enforcement. */
|
|
86
|
+
mode: "open" | "secretary";
|
|
87
|
+
secretaryAgentId?: string;
|
|
84
88
|
sessions: Map<string, AgentSession>;
|
|
85
89
|
/** Session-spawn inputs, kept so sessions can be started lazily. */
|
|
86
90
|
containerName: string;
|
|
@@ -298,6 +302,33 @@ export class Orchestrator {
|
|
|
298
302
|
// -- channel lifecycle ----------------------------------------------------
|
|
299
303
|
|
|
300
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
|
+
}
|
|
301
332
|
this.channelSpecs.set(spec.taskId, spec);
|
|
302
333
|
if (this.blockedTasks.has(spec.taskId)) return;
|
|
303
334
|
// ADR-049: the cloud re-sends the spec on every message AND right after a
|
|
@@ -305,7 +336,6 @@ export class Orchestrator {
|
|
|
305
336
|
// fold the fresh spec in: append new roster agents (their sessions spawn
|
|
306
337
|
// in the reconcile pass of ensureSessions) and rebuild every preamble so
|
|
307
338
|
// a later respawn briefs agents with the CURRENT roster + humans.
|
|
308
|
-
const channel = this.channels.get(spec.taskId);
|
|
309
339
|
if (channel) this.refreshChannel(channel, spec);
|
|
310
340
|
}
|
|
311
341
|
|
|
@@ -334,6 +364,8 @@ export class Orchestrator {
|
|
|
334
364
|
}
|
|
335
365
|
}
|
|
336
366
|
channel.humans = spec.humans ?? [];
|
|
367
|
+
channel.mode = spec.mode === "secretary" ? "secretary" : "open";
|
|
368
|
+
channel.secretaryAgentId = spec.secretaryAgentId;
|
|
337
369
|
channel.browserTesting = spec.browserTesting === true;
|
|
338
370
|
channel.sharedFiles = spec.sharedFiles ?? "ro";
|
|
339
371
|
channel.mcpConnections = spec.mcpConnections ?? [];
|
|
@@ -357,6 +389,8 @@ export class Orchestrator {
|
|
|
357
389
|
channel.humans,
|
|
358
390
|
channel.browserTesting,
|
|
359
391
|
channel.sharedFiles,
|
|
392
|
+
spec.mode,
|
|
393
|
+
spec.secretaryAgentId,
|
|
360
394
|
),
|
|
361
395
|
);
|
|
362
396
|
}
|
|
@@ -428,6 +462,8 @@ export class Orchestrator {
|
|
|
428
462
|
spec.humans,
|
|
429
463
|
spec.browserTesting,
|
|
430
464
|
spec.sharedFiles ?? "ro",
|
|
465
|
+
spec.mode,
|
|
466
|
+
spec.secretaryAgentId,
|
|
431
467
|
),
|
|
432
468
|
);
|
|
433
469
|
// ADR-046: materialise this agent's skills to its per-agent SKILL.md in
|
|
@@ -446,6 +482,8 @@ export class Orchestrator {
|
|
|
446
482
|
const channel: Channel = {
|
|
447
483
|
taskId,
|
|
448
484
|
roster,
|
|
485
|
+
mode: spec.mode === "secretary" ? "secretary" : "open",
|
|
486
|
+
secretaryAgentId: spec.secretaryAgentId,
|
|
449
487
|
sessions: new Map(),
|
|
450
488
|
containerName: `task-${taskId.toLowerCase()}-app-1`,
|
|
451
489
|
preambles,
|
|
@@ -1162,6 +1200,7 @@ export class Orchestrator {
|
|
|
1162
1200
|
agent,
|
|
1163
1201
|
containerName: channel.containerName,
|
|
1164
1202
|
systemPreamble: channel.preambles.get(agent.id) ?? "",
|
|
1203
|
+
executionProfile: this.executionProfileFor(channel, agent.id),
|
|
1165
1204
|
agentEnv: this.accountAgentEnv(
|
|
1166
1205
|
channel,
|
|
1167
1206
|
agent,
|
|
@@ -1185,6 +1224,21 @@ export class Orchestrator {
|
|
|
1185
1224
|
}
|
|
1186
1225
|
}
|
|
1187
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
|
+
|
|
1188
1242
|
/**
|
|
1189
1243
|
* ADR-076: per-agent exec env = the uai token base + the SELECTED engine
|
|
1190
1244
|
* account's env. Picks the least-recently-used, non-cooling account for the
|
|
@@ -1379,9 +1433,10 @@ export class Orchestrator {
|
|
|
1379
1433
|
{
|
|
1380
1434
|
taskId: channel.taskId,
|
|
1381
1435
|
agent,
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1436
|
+
containerName: channel.containerName,
|
|
1437
|
+
systemPreamble: channel.preambles.get(agent.id) ?? "",
|
|
1438
|
+
executionProfile: this.executionProfileFor(channel, agent.id),
|
|
1439
|
+
agentEnv: this.accountAgentEnv(
|
|
1385
1440
|
channel,
|
|
1386
1441
|
agent,
|
|
1387
1442
|
agentCliEnv(
|
|
@@ -1784,6 +1839,7 @@ export class Orchestrator {
|
|
|
1784
1839
|
agent,
|
|
1785
1840
|
containerName: channel.containerName,
|
|
1786
1841
|
systemPreamble: channel.preambles.get(agentId) ?? "",
|
|
1842
|
+
executionProfile: this.executionProfileFor(channel, agentId),
|
|
1787
1843
|
agentEnv: boundAccount
|
|
1788
1844
|
? { ...base, ...boundAccount.execEnv }
|
|
1789
1845
|
: this.accountAgentEnv(channel, agent, base),
|
|
@@ -1902,6 +1958,7 @@ export class Orchestrator {
|
|
|
1902
1958
|
agent,
|
|
1903
1959
|
containerName: channel.containerName,
|
|
1904
1960
|
systemPreamble: channel.preambles.get(agentId) ?? "",
|
|
1961
|
+
executionProfile: this.executionProfileFor(channel, agentId),
|
|
1905
1962
|
agentEnv: { ...base, ...next.execEnv },
|
|
1906
1963
|
},
|
|
1907
1964
|
);
|
|
@@ -2095,6 +2152,18 @@ export class Orchestrator {
|
|
|
2095
2152
|
}
|
|
2096
2153
|
}
|
|
2097
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
|
+
|
|
2098
2167
|
// ---------------------------------------------------------------------------
|
|
2099
2168
|
// `@mention` addressing.
|
|
2100
2169
|
// ---------------------------------------------------------------------------
|
|
@@ -2294,6 +2363,8 @@ export function buildSystemPreamble(
|
|
|
2294
2363
|
humans?: ChannelHuman[],
|
|
2295
2364
|
browserTesting?: boolean,
|
|
2296
2365
|
sharedFiles?: string,
|
|
2366
|
+
mode?: "open" | "secretary",
|
|
2367
|
+
secretaryAgentId?: string,
|
|
2297
2368
|
): string {
|
|
2298
2369
|
const channelList = roster
|
|
2299
2370
|
.map((a) =>
|
|
@@ -2342,6 +2413,139 @@ export function buildSystemPreamble(
|
|
|
2342
2413
|
(p) =>
|
|
2343
2414
|
`- \`${workspacePath}/${p.slug}\` — git worktree on \`${taskBranch}\``,
|
|
2344
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
|
+
];
|
|
2345
2549
|
const comms = [
|
|
2346
2550
|
"## uai task channel",
|
|
2347
2551
|
"",
|
|
@@ -2377,63 +2581,21 @@ export function buildSystemPreamble(
|
|
|
2377
2581
|
"you're waiting on the human), answer briefly and then wait — you don't",
|
|
2378
2582
|
"need to @-mention anyone (including @you); they can see the channel.",
|
|
2379
2583
|
"",
|
|
2380
|
-
|
|
2381
|
-
"human asking the whole group, or a peer addressing multiple agents),",
|
|
2382
|
-
"it's a group broadcast — this is a GROUP CHAT and everyone named has",
|
|
2383
|
-
"ALREADY been notified and will answer for themselves. Just answer for",
|
|
2384
|
-
"YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
|
|
2385
|
-
"question to them, or wait on them — no `I'll let @x speak`, `@x your",
|
|
2386
|
-
"turn`, or `still waiting on @x`. Re-mentioning someone who already got",
|
|
2387
|
-
"the message only wakes them again and spirals into duplicate replies.",
|
|
2388
|
-
"Say your piece and stop.",
|
|
2584
|
+
...groupMessageBrief,
|
|
2389
2585
|
"",
|
|
2390
|
-
|
|
2391
|
-
"context from messages between the human and the other agents. The full",
|
|
2392
|
-
"channel transcript — every message + who wrote it (no tool calls) — is",
|
|
2393
|
-
"logged at `/workspace/.uai/chat.md`. Read it whenever you need that",
|
|
2394
|
-
"context (e.g. the human shared a file or instruction with another",
|
|
2395
|
-
"agent); it's appended live, so re-read it for the latest.",
|
|
2586
|
+
...transcriptBrief,
|
|
2396
2587
|
"",
|
|
2588
|
+
...secretaryRoleBrief,
|
|
2397
2589
|
"Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
|
|
2398
2590
|
"uai hands it back to whoever prompted you — so when you're ANSWERING,",
|
|
2399
2591
|
"just answer plainly; you don't need to re-mention the asker. Mention",
|
|
2400
2592
|
"someone only to bring them in or hand work off. (2) You may occasionally",
|
|
2401
2593
|
"receive a `[channel check-in]` asking you to catch up on the channel.",
|
|
2402
|
-
|
|
2403
|
-
"add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
|
|
2404
|
-
"never shown to anyone, so it is always a safe way to decline a turn.",
|
|
2405
|
-
"",
|
|
2406
|
-
"Hand off when you finish your part of the work. When you've made",
|
|
2407
|
-
"and committed your changes, or completed a review, end your reply by",
|
|
2408
|
-
"@-mentioning the agent who should act next and telling them what you",
|
|
2409
|
-
"did and what you need (e.g. `@codex changes committed on <branch> —",
|
|
2410
|
-
"please review`, or `@claude review done, N issues to fix`). Don't",
|
|
2411
|
-
"abandon unfinished work silently — but once your part is done and no",
|
|
2412
|
-
"peer needs to act, it's fine to stop; only @-mention @you if you need",
|
|
2413
|
-
"their input or are handing back finished work for them to act on. Don't",
|
|
2414
|
-
"prolong an agent-to-agent exchange just to fill silence.",
|
|
2594
|
+
...checkInTranscriptBrief,
|
|
2415
2595
|
"",
|
|
2416
|
-
|
|
2417
|
-
"",
|
|
2418
|
-
`Your shell starts in \`${workspacePath}\` (the task workspace).`,
|
|
2419
|
-
"That directory is **not** itself a git repo — it holds one git",
|
|
2420
|
-
"worktree per project this task spans. Every project below is on the",
|
|
2421
|
-
"same task branch. To run git commands, **`cd` into one of the",
|
|
2422
|
-
"project directories first**:",
|
|
2423
|
-
"",
|
|
2424
|
-
...projectLines,
|
|
2425
|
-
"",
|
|
2426
|
-
`The task branch is \`${taskBranch}\`. Push with \`git push -u origin`,
|
|
2427
|
-
`${taskBranch}\` from inside the project, then open a PR with \`gh pr`,
|
|
2428
|
-
"create` (the container has gh authenticated). For multi-project",
|
|
2429
|
-
"tasks, each project's PR is independent — open one per project whose",
|
|
2430
|
-
"worktree you actually changed.",
|
|
2431
|
-
"",
|
|
2432
|
-
"The `.uai/` directory under each task is uai's own scaffolding",
|
|
2433
|
-
"(rendered Dockerfile, compose file, container scripts) — it is NOT",
|
|
2434
|
-
"part of the project. Never review, edit, stage, commit, or flag it;",
|
|
2435
|
-
"treat it as ignored, even though git may show it as untracked.",
|
|
2596
|
+
...handoffBrief,
|
|
2436
2597
|
"",
|
|
2598
|
+
...workspaceBrief,
|
|
2437
2599
|
// ADR-062: shared files — only when this container actually carries the
|
|
2438
2600
|
// mounts (task-up drops a marker; pre-feature containers have none).
|
|
2439
2601
|
...(sharedFiles &&
|
|
@@ -2442,12 +2604,12 @@ export function buildSystemPreamble(
|
|
|
2442
2604
|
? [
|
|
2443
2605
|
"## Shared files",
|
|
2444
2606
|
"",
|
|
2445
|
-
`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):`,
|
|
2446
2608
|
"- `/workspace/files/org` — the org's shared files (logos, specs,",
|
|
2447
2609
|
" datasets), visible to every task in the org on this host.",
|
|
2448
2610
|
"- `/workspace/files/me` — the task owner's personal files, shared",
|
|
2449
2611
|
" across their tasks on this host.",
|
|
2450
|
-
...(sharedFiles === "rw"
|
|
2612
|
+
...(sharedFiles === "rw" && !isSecretary
|
|
2451
2613
|
? [
|
|
2452
2614
|
"When producing artifacts for humans, write them here (use a",
|
|
2453
2615
|
"subdirectory named after the task to avoid collisions).",
|
|
@@ -2474,7 +2636,8 @@ export function buildSystemPreamble(
|
|
|
2474
2636
|
// ADR-047: package skills are native Claude Agent Skills installed into the
|
|
2475
2637
|
// container's skills dir (Claude-only). List them so the agent knows they're
|
|
2476
2638
|
// available even if headless auto-discovery is unreliable.
|
|
2477
|
-
...(
|
|
2639
|
+
...(!isSecretary &&
|
|
2640
|
+
agent.kind === "claude" &&
|
|
2478
2641
|
(agent.skills ?? []).some((s) => s.type === "package")
|
|
2479
2642
|
? [
|
|
2480
2643
|
"## Installed skills",
|
|
@@ -2488,7 +2651,7 @@ export function buildSystemPreamble(
|
|
|
2488
2651
|
]
|
|
2489
2652
|
: []),
|
|
2490
2653
|
// ADR-053: the in-container browser, when the project opted in.
|
|
2491
|
-
...(browserTesting
|
|
2654
|
+
...(browserTesting && !isSecretary
|
|
2492
2655
|
? [
|
|
2493
2656
|
"## Browser",
|
|
2494
2657
|
"",
|
|
@@ -2511,7 +2674,11 @@ export function buildSystemPreamble(
|
|
|
2511
2674
|
]
|
|
2512
2675
|
: []),
|
|
2513
2676
|
// ADR-048: tell agents with permissions about their `uai` CLI.
|
|
2514
|
-
|
|
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
|
|
2515
2682
|
? [
|
|
2516
2683
|
"## The uai CLI",
|
|
2517
2684
|
"",
|
|
@@ -2601,14 +2768,18 @@ export function buildSystemPreamble(
|
|
|
2601
2768
|
"",
|
|
2602
2769
|
]
|
|
2603
2770
|
: []),
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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
|
+
: []),
|
|
2612
2783
|
].join("\n");
|
|
2613
2784
|
|
|
2614
2785
|
// Persona / mission layers, always-on so they apply to every turn:
|
|
@@ -2636,11 +2807,20 @@ const globalForOrchestrator = globalThis as unknown as {
|
|
|
2636
2807
|
|
|
2637
2808
|
export function getOrchestrator(): Orchestrator {
|
|
2638
2809
|
if (!globalForOrchestrator.__uaiOrchestrator) {
|
|
2639
|
-
//
|
|
2640
|
-
//
|
|
2641
|
-
//
|
|
2642
|
-
|
|
2643
|
-
|
|
2810
|
+
// Real Claude/Codex CLIs inside the task containers unless `UAI_AGENTS`
|
|
2811
|
+
// explicitly asks for the echo mock (see ./agents/mode). Mock is loud —
|
|
2812
|
+
// a host that answers every prompt with canned text has to say so, or it
|
|
2813
|
+
// reads as a working host producing nonsense.
|
|
2814
|
+
const { mode, warning } = resolveAgentMode();
|
|
2815
|
+
if (warning) console.warn(`[host-agent] ${warning}`);
|
|
2816
|
+
if (mode === "mock") {
|
|
2817
|
+
console.warn(
|
|
2818
|
+
"[host-agent] ⚠ MOCK AGENTS — every agent echoes canned text and no " +
|
|
2819
|
+
"real CLI runs. Unset UAI_AGENTS (or set it to `real`) for a host " +
|
|
2820
|
+
"that does actual work.",
|
|
2821
|
+
);
|
|
2822
|
+
}
|
|
2823
|
+
const factory = mode === "real" ? realAgentFactory : mockAgentFactory;
|
|
2644
2824
|
globalForOrchestrator.__uaiOrchestrator = new Orchestrator(factory);
|
|
2645
2825
|
}
|
|
2646
2826
|
if (!globalForOrchestrator.__uaiRecoverRan) {
|
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.
|
|
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
|
-
|
|
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.
|
|
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>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"uai-host": "./bin/uai-host.mjs"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
|
-
"node": ">=
|
|
31
|
+
"node": "22.x || >=24 <27"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@sentry/node": "^10.66.0",
|
|
70
|
-
"better-sqlite3": "
|
|
70
|
+
"better-sqlite3": "12.11.1",
|
|
71
71
|
"dotenv": "^16.4.5",
|
|
72
72
|
"drizzle-orm": "^0.36.0",
|
|
73
73
|
"tsx": "^4.19.2",
|
package/src/cli.ts
CHANGED
|
@@ -134,6 +134,13 @@ async function cmdStatus(): Promise<void> {
|
|
|
134
134
|
);
|
|
135
135
|
if (cloud.lastError) console.log(` ${dim("last error: " + cloud.lastError)}`);
|
|
136
136
|
console.log(` service ${dim(`pid ${status.pid} · up ${fmtDuration(status.uptime * 1000)}`)}`);
|
|
137
|
+
// Only worth a line when it's the surprising answer. A mock host looks
|
|
138
|
+
// identical to a real one everywhere else — connected, tasks running, output
|
|
139
|
+
// streaming — so this is the only place it can be caught before the work is.
|
|
140
|
+
if (status.agentMode === "mock") {
|
|
141
|
+
console.log(` agents ${yellow("mock")} ${dim("— echo only, no real CLI runs")}`);
|
|
142
|
+
console.log(` ${dim("unset UAI_AGENTS in " + envLocalPath() + ", then: uai-host restart")}`);
|
|
143
|
+
}
|
|
137
144
|
console.log(` ui ${dim(`http://127.0.0.1:${port}`)}`);
|
|
138
145
|
console.log(` log ${dim(status.logPath)}`);
|
|
139
146
|
console.log(` tasks ${tasks.tasks.length === 0 ? dim("none") : tasks.tasks.length}`);
|
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
|
|
package/src/ui/server.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { join } from "node:path";
|
|
|
23
23
|
import { desc, isNull } from "drizzle-orm";
|
|
24
24
|
import type { ZodType } from "zod";
|
|
25
25
|
|
|
26
|
+
import { agentMode } from "../../lib/agents/mode";
|
|
26
27
|
import { schema, type Db } from "../../lib/db";
|
|
27
28
|
import { parsePreviewPortRuntimes } from "../../lib/preview-ports";
|
|
28
29
|
import { getCloudState } from "../../lib/cloud-state";
|
|
@@ -186,6 +187,7 @@ async function handle(
|
|
|
186
187
|
cloudUrl: opts.cloudUrl,
|
|
187
188
|
hostName: hostname(),
|
|
188
189
|
hostId: opts.hostId,
|
|
190
|
+
agentMode: agentMode(),
|
|
189
191
|
});
|
|
190
192
|
case "/api/cloud":
|
|
191
193
|
return sendJson(res, CloudResponse, getCloudState());
|
package/src/ui/types.ts
CHANGED
|
@@ -17,6 +17,11 @@ export const StatusResponse = z.object({
|
|
|
17
17
|
cloudUrl: z.string(), // UAI_CLOUD_URL
|
|
18
18
|
hostName: z.string(), // os.hostname()
|
|
19
19
|
hostId: z.string(), // UAI_HOST_ID
|
|
20
|
+
// Which agent adapters this host runs (UAI_AGENTS). Optional: a newer CLI
|
|
21
|
+
// can query a service that is still on the pre-upgrade build, which had no
|
|
22
|
+
// such field — `npm i -g` swaps the CLI before `uai-host restart` swaps the
|
|
23
|
+
// service, so that window is normal, not exotic.
|
|
24
|
+
agentMode: z.enum(["real", "mock"]).optional(),
|
|
20
25
|
});
|
|
21
26
|
export type StatusResponse = z.infer<typeof StatusResponse>;
|
|
22
27
|
|