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