@runuai/host 0.9.142 → 0.9.143

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.
@@ -44,6 +44,7 @@ import {
44
44
  import { ACTIVE_STATUSES, isActive, isOnDashboard } from "./task-status";
45
45
  import { isSafeHostTaskId } from "./task-identity";
46
46
  import { projectEnvForTask } from "./host-env";
47
+ import { shadowRoute } from "./routing/shadow";
47
48
  import { getHostTask, hostTaskProjectIds, upsertHostTask } from "./runtime-state";
48
49
  import { clearRefresh, reconcileTaskGitAuth } from "./github-tokens";
49
50
  import { ensureTaskSshIdentity, setupTaskGitIdentity } from "./git-identity";
@@ -299,6 +300,10 @@ interface Channel {
299
300
  * agent bound at this instant" was never true for a mixed roster after a
300
301
  * wake — live 2026-09-10, task machine, 4 agents, screen up for good. */
301
302
  everBound: Set<string>;
303
+ /** ADR-141 rung 1: report what Auto would have routed on every delivery. */
304
+ routingShadow: boolean;
305
+ /** The task's workspace path on the environment (from the channel spec). */
306
+ workspacePath: string;
302
307
  /** Factory creates are externally side-effecting before they bind. Teardown
303
308
  * drains them so an old channel cannot resume against a reused container
304
309
  * name and overwrite the replacement generation's durable transport. */
@@ -564,6 +569,8 @@ export class Orchestrator {
564
569
  channel.mode = spec.mode === "secretary" ? "secretary" : "open";
565
570
  channel.secretaryAgentId = spec.secretaryAgentId;
566
571
  channel.browserTesting = spec.browserTesting === true;
572
+ channel.routingShadow = spec.routing?.shadow === true;
573
+ channel.workspacePath = spec.workspacePath;
567
574
  channel.sharedFiles = spec.sharedFiles ?? "ro";
568
575
  channel.mcpConnections = spec.mcpConnections ?? [];
569
576
  if (spec.engineAccountScope) channel.engineAccountScope = spec.engineAccountScope;
@@ -724,6 +731,8 @@ export class Orchestrator {
724
731
  configGeneration: 0,
725
732
  spawning: new Set(),
726
733
  everBound: new Set(),
734
+ routingShadow: spec.routing?.shadow === true,
735
+ workspacePath: spec.workspacePath,
727
736
  factoryOperations: new Set(),
728
737
  eventOperations: new Set(),
729
738
  accountByAgent: new Map(),
@@ -2361,9 +2370,97 @@ export class Orchestrator {
2361
2370
  // turn boundary because crew replies can arrive during its dispatch turn.
2362
2371
  this.startPrompt(channel, agentId, session, prompt);
2363
2372
  }
2373
+ // ADR-141 rung 1: after the turn is on its way, ask what Auto would have
2374
+ // done and report it. Fire-and-forget by design — it must never delay or
2375
+ // fail a delivery, so every error ends here.
2376
+ if (channel.routingShadow) {
2377
+ void this.shadowRouteDelivery(channel, agentId, text).catch((err) => {
2378
+ console.warn(
2379
+ `[routing] shadow pass failed for ${channel.taskId}/${agentId}: ${
2380
+ err instanceof Error ? err.message : String(err)
2381
+ }`,
2382
+ );
2383
+ });
2384
+ }
2364
2385
  return { ok: true };
2365
2386
  }
2366
2387
 
2388
+ /**
2389
+ * ADR-141 rung 1. Runs the shadow router for one delivery: the classifier
2390
+ * one-shot executes INSIDE the task environment as the task user, with the
2391
+ * same engine account the agent runs on (so the user's own subscription
2392
+ * pays for routing, never Uai), then the result rides a host event. Bounded
2393
+ * by a hard timeout so a hung CLI cannot pile up passes.
2394
+ */
2395
+ private async shadowRouteDelivery(channel: Channel, agentId: string, text: string): Promise<void> {
2396
+ const agent = channel.roster.find((a) => a.id === agentId);
2397
+ if (!agent) return;
2398
+ // Use the SETTLED handle, never await the environment promise: a task
2399
+ // still provisioning must route by rules now, not block the pass until
2400
+ // task-up finishes (and a never-resolving handle would hang it forever).
2401
+ // A test double may also lack methods — none of this may stop the pass.
2402
+ const handle = channel.agentHandle;
2403
+ const canExec = typeof handle?.exec === "function";
2404
+ const canRead = typeof handle?.readWorkspaceFile === "function";
2405
+ const workspace = handle?.descriptor?.workspacePath ?? channel.workspacePath;
2406
+ const scratchDir = `${workspace}/.uai`;
2407
+ // Rung 1 hosts the classifier on the agent's own engine; the Managed
2408
+ // Engine delegates to claude/codex, so borrow the delegate's account.
2409
+ const hostEngine = agent.kind === "uai" ? (agent.uaiProvider === "openai" ? "codex" : "claude") : agent.kind;
2410
+ const account = pickEngineAccount(hostEngine, { scope: channel.engineAccountScope });
2411
+ const env: Record<string, string> = { ...(account?.execEnv ?? {}) };
2412
+ const context =
2413
+ handle && canRead
2414
+ ? Buffer.from((await handle.readWorkspaceFile(`${scratchDir}/chat.md`).catch(() => null)) ?? new Uint8Array())
2415
+ .toString("utf8")
2416
+ .slice(-1200)
2417
+ : "";
2418
+ const timeoutMs = 45_000;
2419
+ const result = await Promise.race([
2420
+ shadowRoute(text, context, hostEngine, {
2421
+ scratchDir,
2422
+ exec: async (argv) => {
2423
+ if (!handle || !canExec) return null;
2424
+ const r = await handle.exec({
2425
+ argv,
2426
+ cwd: scratchDir,
2427
+ user: "node",
2428
+ env,
2429
+ timeoutMs,
2430
+ maxOutputBytes: 64 * 1024,
2431
+ });
2432
+ return { exitCode: r.exitCode, stdout: Buffer.from(r.stdout).toString("utf8") };
2433
+ },
2434
+ readFile: async (path) => {
2435
+ if (!handle || !canRead) return null;
2436
+ const bytes = await handle.readWorkspaceFile(path).catch(() => null);
2437
+ return bytes ? Buffer.from(bytes).toString("utf8") : null;
2438
+ },
2439
+ }),
2440
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs + 5_000)),
2441
+ ]);
2442
+ if (!result) {
2443
+ console.warn(`[routing] shadow pass timed out for ${channel.taskId}/${agentId}`);
2444
+ return;
2445
+ }
2446
+ this.emitHost({
2447
+ kind: "agent.route.shadow",
2448
+ taskId: channel.taskId,
2449
+ agentId,
2450
+ at: Date.now(),
2451
+ class: result.cls,
2452
+ workKind: result.kind,
2453
+ via: result.via,
2454
+ host: result.host,
2455
+ wouldEngine: result.would.engine,
2456
+ wouldModel: result.would.model,
2457
+ actualEngine: agent.kind,
2458
+ actualModel: agent.model ?? null,
2459
+ actualEffort: agent.effort ?? null,
2460
+ ms: result.ms,
2461
+ });
2462
+ }
2463
+
2367
2464
  /** Interrupt an agent's current turn (ESC). No-op when the task/agent has no
2368
2465
  * live session (nothing to stop). */
2369
2466
  async interrupt(
@@ -0,0 +1,261 @@
1
+ /**
2
+ * ADR-141 rung 1 — the SHADOW router.
3
+ *
4
+ * Nothing here changes what runs. On every human delivery to an agent in a
5
+ * workspace the cloud flagged (`ChannelEnsureInput.routing.shadow`), the
6
+ * runner classifies the message the way Auto mode would, resolves what Auto
7
+ * would have picked under the Balanced preset, and reports both beside what
8
+ * actually ran as an `agent.route.shadow` host event. A week of those rows
9
+ * turns the on-paper saving into a measured one.
10
+ *
11
+ * Shape, kept deliberately: the MODEL answers one fuzzy question ("how hard
12
+ * is this, and what kind of work"); everything with policy in it is code.
13
+ *
14
+ * fast path (rules) → classify (one-shot on the agent's own account)
15
+ * → hedge (rules that force the ceiling)
16
+ * → resolve (class → tier → model within the envelope)
17
+ *
18
+ * The one-shot runs INSIDE the task environment, as `node`, with the engine
19
+ * account the agent itself runs on — so Uai never pays for routing and the
20
+ * classifier sees the same credential the agent does. Rung 0 (2026-09-15)
21
+ * showed Claude, Codex and Grok all host the rubric within the noise floor
22
+ * (74–77% tier agreement, Haiku against itself included), so the host is
23
+ * simply whichever engine the agent is on.
24
+ */
25
+
26
+ export const ROUTE_CLASSES = ["trivial", "routine", "hard", "decision"] as const;
27
+ export type RouteClass = (typeof ROUTE_CLASSES)[number];
28
+ export const WORK_KINDS = ["edit", "debug", "explain", "plan", "research", "write", "browser", "other"] as const;
29
+ export type WorkKind = (typeof WORK_KINDS)[number];
30
+
31
+ /** hard and decision both route to the ceiling; that is what the resolver
32
+ * compares, and what rung 0 measured agreement on. */
33
+ export const TIER_OF: Record<RouteClass, 0 | 1 | 2> = {
34
+ trivial: 0,
35
+ routine: 1,
36
+ hard: 2,
37
+ decision: 2,
38
+ };
39
+
40
+ export const RUBRIC = `You classify ONE human message sent to a coding agent inside a task channel.
41
+
42
+ Pick exactly one class:
43
+ - trivial: acknowledgements, greetings, yes/no, "go ahead", "continue", thanks,
44
+ a one-line question about current state. No repository work is needed.
45
+ - routine: a clear, bounded ask done in one or two steps: explain a file, small
46
+ fix, rename, run the tests, answer a factual question about the code.
47
+ - hard: multi-step implementation, debugging where the cause is unknown, a
48
+ change across several files, a bug report, anything needing exploration first.
49
+ - decision: architecture, design or trade-off questions, planning, "should we…",
50
+ reviewing a proposal — judgement matters more than execution.
51
+
52
+ And one kind of work: edit, debug, explain, plan, research, write, browser, other.
53
+
54
+ The preceding channel turns are context only. Judge the LAST human message.
55
+ Return ONLY this JSON, nothing else:
56
+ {"class":"<trivial|routine|hard|decision>","kind":"<edit|debug|explain|plan|research|write|browser|other>","reason":"<8 words max>"}`;
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Fast path: turns that never need the model. Rung 0's regex caught only 10 of
60
+ // 135 trivials; this one is wider on purpose and still conservative: short,
61
+ // no path, no code, no question about anything specific.
62
+ const ACK = new RegExp(
63
+ "^(ok(ay)?|yes|yeah|yep|ya|no|nope|go|go ahead|continue|proceed|do it|ship it|" +
64
+ "thanks|thank you|ty|thx|cool|nice|great|good|done|sure|k|kk|lgtm|approved|" +
65
+ "sounds good|perfect|awesome|please do|yes please|no thanks|not now|later|" +
66
+ "👍|🙏|✅)[.!\\s]*$",
67
+ "i",
68
+ );
69
+ const LOOKS_LIKE_WORK = /[\/\\`{}()=;]|\.(ts|js|tsx|py|md|json|sql|sh|yml|yaml)\b|https?:\/\//i;
70
+
71
+ export function fastPath(text: string): RouteClass | null {
72
+ const t = text.trim();
73
+ if (t.length === 0) return "trivial";
74
+ if (ACK.test(t)) return "trivial";
75
+ // A very short message with no code-shaped content and no question mark
76
+ // is a reaction, not a request ("k .11 is shipping", "@luigi").
77
+ if (t.length <= 24 && !LOOKS_LIKE_WORK.test(t) && !t.includes("?")) return "trivial";
78
+ return null;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Hedges: deterministic upgrades to the ceiling. Rung 0's systematic miss was
83
+ // bug reports and multi-ask messages labelled routine; these cut Codex's cold
84
+ // misses from 6 to 3 of 51 at one needless escalation in 70.
85
+ const BUG = /\b(bug|broken|not working|doesn'?t work|isn'?t working|won'?t|error|fail(ed|ing|s)?|duplicated|wrong|stuck|crash(ed|es)?|regression|não está|nao esta|não funciona|quebr(ou|ado))\b/i;
86
+ const ATTACH = /\[image:|attachments\/|screenshot|\bprint\b/i;
87
+ const MULTI = /(\n\s*[-*•]|\n\s*\d+[.)-]|another thing|a couple more|one more thing|also,|and then|then i want|\b[12]-\s)/i;
88
+ const DECIDE = /\b(should we|how is that gonna|how would that|what should|what would|do we need|is it (risky|worth|safe)|let'?s think|thoughts on|what do you think)\b/i;
89
+
90
+ export function hedge(text: string, cls: RouteClass): RouteClass {
91
+ if (TIER_OF[cls] === 2) return cls;
92
+ const long = text.length > 60;
93
+ if (DECIDE.test(text) && text.length > 40) return "decision";
94
+ if (BUG.test(text) && (ATTACH.test(text) || long)) return "hard";
95
+ if (MULTI.test(text) && text.length > 80) return "hard";
96
+ return cls;
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Resolve: class → tier → model, per engine, under the Balanced preset. Rung 1
101
+ // keeps the agent's own engine (cross-engine is rung 3) and a static tier
102
+ // table; the catalog's tier column replaces this once it exists.
103
+ export interface WouldRoute {
104
+ engine: string;
105
+ model: string | null;
106
+ }
107
+
108
+ const BALANCED: Record<string, [string, string, string] | undefined> = {
109
+ // [floor, default, ceiling]
110
+ claude: ["haiku", "sonnet", "opus"],
111
+ uai: ["haiku-4.5", "sonnet-5", "opus-5"],
112
+ codex: ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"],
113
+ };
114
+
115
+ export function resolveBalanced(cls: RouteClass, engine: string): WouldRoute {
116
+ const ladder = BALANCED[engine];
117
+ if (!ladder) return { engine, model: null };
118
+ return { engine, model: ladder[TIER_OF[cls]] };
119
+ }
120
+
121
+ /** The cheapest model each engine serves on a connected account — what the
122
+ * classifier itself runs on. Verified 2026-09-15: a ChatGPT-account Codex
123
+ * refuses gpt-5-mini and the codex-mini variants but serves Luna. */
124
+ export const CLASSIFIER_MODEL: Record<string, string | null> = {
125
+ claude: "haiku",
126
+ codex: "gpt-5.6-luna",
127
+ grok: null, // the CLI's default; the login decides
128
+ };
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // One-shot completion argv per engine. No tools, no session, plain text back.
132
+ export interface OneShot {
133
+ argv: [string, ...string[]];
134
+ /** Where the answer lands: the process's stdout, or a file it writes. */
135
+ output: { kind: "stdout-json"; field: string } | { kind: "file"; path: string };
136
+ }
137
+
138
+ export function oneShotFor(engine: string, prompt: string, scratchDir: string): OneShot | null {
139
+ switch (engine) {
140
+ case "claude":
141
+ case "uai":
142
+ return {
143
+ argv: ["claude", "-p", prompt, "--model", "haiku", "--output-format", "json", "--no-session-persistence"],
144
+ output: { kind: "stdout-json", field: "result" },
145
+ };
146
+ case "codex": {
147
+ const out = `${scratchDir}/router-last.txt`;
148
+ return {
149
+ argv: [
150
+ "codex", "exec", "--skip-git-repo-check", "--ephemeral", "-s", "read-only",
151
+ "-m", "gpt-5.6-luna", "-o", out, prompt,
152
+ ],
153
+ output: { kind: "file", path: out },
154
+ };
155
+ }
156
+ case "grok":
157
+ return {
158
+ argv: ["grok", "-p", prompt, "--output-format", "json", "--disable-web-search"],
159
+ output: { kind: "stdout-json", field: "text" },
160
+ };
161
+ default:
162
+ return null;
163
+ }
164
+ }
165
+
166
+ export interface Label {
167
+ cls: RouteClass;
168
+ kind: WorkKind | null;
169
+ reason: string;
170
+ }
171
+
172
+ /** Tolerant of chatter around the JSON and of a model inventing a class. */
173
+ export function parseLabel(text: string): Label | null {
174
+ const m = text.match(/\{[^{}]*"class"[^{}]*\}/);
175
+ if (!m) return null;
176
+ try {
177
+ const o = JSON.parse(m[0]) as { class?: unknown; kind?: unknown; reason?: unknown };
178
+ const cls = String(o.class ?? "").toLowerCase() as RouteClass;
179
+ if (!ROUTE_CLASSES.includes(cls)) return null;
180
+ const kind = String(o.kind ?? "").toLowerCase() as WorkKind;
181
+ return {
182
+ cls,
183
+ kind: WORK_KINDS.includes(kind) ? kind : null,
184
+ reason: typeof o.reason === "string" ? o.reason.slice(0, 80) : "",
185
+ };
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ export function buildPrompt(message: string, context: string): string {
192
+ const ctx = context.trim().length > 0 ? context.trim().slice(-900) : "(none)";
193
+ return `${RUBRIC}\n\nRECENT CHANNEL (context only):\n${ctx}\n\nLAST HUMAN MESSAGE:\n${message.slice(0, 1600)}`;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // The pass itself, with every side effect injected so it is testable without
198
+ // an orchestrator.
199
+ export interface ShadowDeps {
200
+ /** Run a one-shot in the task environment; null when it cannot run at all. */
201
+ exec: (argv: [string, ...string[]]) => Promise<{ exitCode: number | null; stdout: string } | null>;
202
+ readFile: (path: string) => Promise<string | null>;
203
+ scratchDir: string;
204
+ now?: () => number;
205
+ }
206
+
207
+ export interface ShadowResult {
208
+ cls: RouteClass;
209
+ kind: WorkKind | null;
210
+ via: "rules" | "account" | "none";
211
+ host: string | null;
212
+ would: WouldRoute;
213
+ ms: number;
214
+ }
215
+
216
+ export async function shadowRoute(
217
+ message: string,
218
+ context: string,
219
+ engine: string,
220
+ deps: ShadowDeps,
221
+ ): Promise<ShadowResult> {
222
+ const now = deps.now ?? Date.now;
223
+ const t0 = now();
224
+ const fast = fastPath(message);
225
+ if (fast) {
226
+ return { cls: fast, kind: null, via: "rules", host: null, would: resolveBalanced(fast, engine), ms: now() - t0 };
227
+ }
228
+ const shot = oneShotFor(engine, buildPrompt(message, context), deps.scratchDir);
229
+ let label: Label | null = null;
230
+ let host: string | null = null;
231
+ if (shot) {
232
+ const res = await deps.exec(shot.argv).catch(() => null);
233
+ if (res && res.exitCode === 0) {
234
+ let text = "";
235
+ if (shot.output.kind === "file") {
236
+ text = (await deps.readFile(shot.output.path).catch(() => null)) ?? "";
237
+ } else {
238
+ try {
239
+ const d = JSON.parse(res.stdout) as Record<string, unknown>;
240
+ text = typeof d[shot.output.field] === "string" ? (d[shot.output.field] as string) : res.stdout;
241
+ } catch {
242
+ text = res.stdout;
243
+ }
244
+ }
245
+ label = parseLabel(text);
246
+ if (label) host = engine;
247
+ }
248
+ }
249
+ // No usable answer: the deterministic layer still routes — default tier,
250
+ // hedged upward where the message says so.
251
+ const base: RouteClass = label?.cls ?? "routine";
252
+ const cls = hedge(message, base);
253
+ return {
254
+ cls,
255
+ kind: label?.kind ?? null,
256
+ via: label ? "account" : "none",
257
+ host,
258
+ would: resolveBalanced(cls, engine),
259
+ ms: now() - t0,
260
+ };
261
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.142",
3
+ "version": "0.9.143",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/protocol.ts CHANGED
@@ -1325,6 +1325,9 @@ export interface ChannelEnsureInput {
1325
1325
  /** ADR-062: shared-files mount mode — drives the preamble "## Files"
1326
1326
  * section ("off" | "ro" | "rw"). */
1327
1327
  sharedFiles?: string;
1328
+ /** ADR-141 rung 1: `shadow` asks the runner to classify every human
1329
+ * delivery and report what Auto mode WOULD have routed, changing nothing. */
1330
+ routing?: { shadow?: boolean };
1328
1331
  globalContext?: string;
1329
1332
  projects: Array<{ slug: string; defaultPrompt: string }>;
1330
1333
  branch: string;
@@ -1973,6 +1976,24 @@ export type HostEvent =
1973
1976
  tool: string;
1974
1977
  meta: unknown;
1975
1978
  }
1979
+ // ADR-141 rung 1: one row per human delivery in a shadow-routed workspace.
1980
+ // `would` is the Balanced-preset pick; `actual` is what the agent runs on.
1981
+ | {
1982
+ kind: "agent.route.shadow";
1983
+ taskId: string;
1984
+ agentId: string;
1985
+ at: number;
1986
+ class: string;
1987
+ workKind: string | null;
1988
+ via: "rules" | "account" | "none";
1989
+ host: string | null;
1990
+ wouldEngine: string;
1991
+ wouldModel: string | null;
1992
+ actualEngine: string;
1993
+ actualModel: string | null;
1994
+ actualEffort: string | null;
1995
+ ms: number;
1996
+ }
1976
1997
  | {
1977
1998
  kind: "agent.permission_request";
1978
1999
  taskId: string;