@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.
- package/lib/orchestrator.ts +97 -0
- package/lib/routing/shadow.ts +261 -0
- package/package.json +1 -1
- package/src/protocol.ts +21 -0
package/lib/orchestrator.ts
CHANGED
|
@@ -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
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;
|