@runuai/host 0.1.0
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/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/uai-host.mjs +14 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/meta/_journal.json +41 -0
- package/db/schema.ts +82 -0
- package/images/standard/Dockerfile +232 -0
- package/images/standard/README.md +122 -0
- package/images/standard/container/code-server-settings.json +36 -0
- package/images/standard/container/uai-init +215 -0
- package/images/standard/tool-versions +2 -0
- package/lib/agent.ts +292 -0
- package/lib/agents/claude.ts +343 -0
- package/lib/agents/codex.ts +522 -0
- package/lib/agents/factory.ts +34 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/proc.ts +172 -0
- package/lib/agents/registry.ts +109 -0
- package/lib/agents/types.ts +133 -0
- package/lib/attachments.ts +46 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/command-db.ts +278 -0
- package/lib/db.ts +68 -0
- package/lib/env.ts +140 -0
- package/lib/git-diff.ts +370 -0
- package/lib/git-identity.ts +65 -0
- package/lib/github-tokens.ts +321 -0
- package/lib/orchestrator.ts +975 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/repo-clone.ts +127 -0
- package/lib/runtime-state.ts +120 -0
- package/lib/secrets.ts +71 -0
- package/lib/ssh.ts +186 -0
- package/lib/standard-image.ts +152 -0
- package/lib/task-diff.ts +113 -0
- package/lib/task-status.ts +46 -0
- package/lib/transcript.ts +30 -0
- package/lib/ulid.ts +7 -0
- package/package.json +85 -0
- package/scripts/agent/_common.sh +248 -0
- package/scripts/agent/task-down.sh +113 -0
- package/scripts/agent/task-status.sh +54 -0
- package/scripts/agent/task-up.sh +457 -0
- package/scripts/install/darwin.ts +167 -0
- package/scripts/install/linux.ts +115 -0
- package/scripts/install/types.ts +35 -0
- package/scripts/install/util.ts +39 -0
- package/scripts/install/win.ts +130 -0
- package/src/cli.ts +445 -0
- package/src/index.ts +375 -0
- package/src/load-env.ts +52 -0
- package/src/main.ts +1156 -0
- package/src/paths.ts +64 -0
- package/src/protocol.ts +413 -0
- package/src/ui/server.ts +343 -0
- package/src/ui/types.ts +78 -0
- package/ui/app.js +264 -0
- package/ui/index.html +55 -0
- package/ui/style.css +359 -0
- package/ui/uai-logo-black.svg +9 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LineProcess — a child process whose stdout is consumed as
|
|
3
|
+
* newline-delimited text (one JSON value per line).
|
|
4
|
+
*
|
|
5
|
+
* Both real agent adapters (ClaudeSession, CodexSession) drive a CLI
|
|
6
|
+
* running *inside the task container* (ADR-010) via
|
|
7
|
+
* `docker exec -i task-<id>-app-1 <cli> …`. That CLI speaks
|
|
8
|
+
* newline-delimited JSON over stdio — Claude's stream-json, Codex's
|
|
9
|
+
* JSON-RPC. This class owns the mechanics shared by both: spawn,
|
|
10
|
+
* line-buffer stdout, capture stderr, write lines to stdin, tear down.
|
|
11
|
+
*
|
|
12
|
+
* It is deliberately protocol-agnostic — it knows nothing about
|
|
13
|
+
* stream-json or JSON-RPC. The adapters layer that on top.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
export interface LineProcessOptions {
|
|
19
|
+
/** Executable — almost always "docker". */
|
|
20
|
+
command: string;
|
|
21
|
+
/** Args — e.g. ["exec", "-i", "task-…-app-1", "claude", …]. */
|
|
22
|
+
args: string[];
|
|
23
|
+
/**
|
|
24
|
+
* When set *and* `UAI_DEBUG_AGENTS` is truthy in the environment,
|
|
25
|
+
* spawn / raw stdio / exit are logged to the server console with
|
|
26
|
+
* this label — the way to see what an agent CLI is actually doing.
|
|
27
|
+
*/
|
|
28
|
+
debugLabel?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type LineHandler = (line: string) => void;
|
|
32
|
+
export type ExitHandler = (code: number | null) => void;
|
|
33
|
+
|
|
34
|
+
/** How much trailing stderr to retain for error reporting. */
|
|
35
|
+
const STDERR_CAP = 8 * 1024;
|
|
36
|
+
|
|
37
|
+
export class LineProcess {
|
|
38
|
+
private readonly child: ChildProcess;
|
|
39
|
+
private stdoutBuf = "";
|
|
40
|
+
private stderrBuf = "";
|
|
41
|
+
private readonly lineHandlers = new Set<LineHandler>();
|
|
42
|
+
private readonly exitHandlers = new Set<ExitHandler>();
|
|
43
|
+
private closed = false;
|
|
44
|
+
private readonly debug: string | null;
|
|
45
|
+
|
|
46
|
+
constructor(opts: LineProcessOptions) {
|
|
47
|
+
this.debug =
|
|
48
|
+
opts.debugLabel && process.env.UAI_DEBUG_AGENTS ? opts.debugLabel : null;
|
|
49
|
+
if (this.debug) {
|
|
50
|
+
this.log(`spawn: ${opts.command} ${opts.args.join(" ")}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.child = spawn(opts.command, opts.args, {
|
|
54
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
this.child.stdout?.setEncoding("utf8");
|
|
58
|
+
this.child.stdout?.on("data", (chunk: string) => this.onStdout(chunk));
|
|
59
|
+
|
|
60
|
+
this.child.stderr?.setEncoding("utf8");
|
|
61
|
+
this.child.stderr?.on("data", (chunk: string) => {
|
|
62
|
+
// Keep only the tail — stderr can be noisy, we want the last words.
|
|
63
|
+
this.stderrBuf = (this.stderrBuf + chunk).slice(-STDERR_CAP);
|
|
64
|
+
if (this.debug) this.log(`stderr: ${chunk.trimEnd()}`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.child.on("exit", (code) => this.finish(code));
|
|
68
|
+
this.child.on("error", (err) => {
|
|
69
|
+
if (this.debug) this.log(`spawn error: ${err.message}`);
|
|
70
|
+
this.finish(null);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private log(msg: string): void {
|
|
75
|
+
console.error(`[uai-agent ${this.debug}] ${msg}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private finish(code: number | null): void {
|
|
79
|
+
if (this.closed) return;
|
|
80
|
+
this.closed = true;
|
|
81
|
+
if (this.debug) this.log(`exit: code ${code}`);
|
|
82
|
+
for (const h of this.exitHandlers) h(code);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Split the rolling buffer on newlines; emit each complete line. */
|
|
86
|
+
private onStdout(chunk: string): void {
|
|
87
|
+
this.stdoutBuf += chunk;
|
|
88
|
+
let nl: number;
|
|
89
|
+
while ((nl = this.stdoutBuf.indexOf("\n")) >= 0) {
|
|
90
|
+
const line = this.stdoutBuf.slice(0, nl).trim();
|
|
91
|
+
this.stdoutBuf = this.stdoutBuf.slice(nl + 1);
|
|
92
|
+
if (line.length === 0) continue;
|
|
93
|
+
if (this.debug) this.log(`<- ${line.slice(0, 1000)}`);
|
|
94
|
+
for (const h of this.lineHandlers) {
|
|
95
|
+
try {
|
|
96
|
+
h(line);
|
|
97
|
+
} catch {
|
|
98
|
+
// A broken handler must not wedge the read loop.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Subscribe to complete stdout lines. */
|
|
105
|
+
onLine(handler: LineHandler): void {
|
|
106
|
+
this.lineHandlers.add(handler);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Subscribe to process exit. `code` is null on spawn error. */
|
|
110
|
+
onExit(handler: ExitHandler): void {
|
|
111
|
+
this.exitHandlers.add(handler);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Serialise `value` as JSON and write it as one newline-delimited line. */
|
|
115
|
+
writeLine(value: unknown): void {
|
|
116
|
+
if (this.closed) return;
|
|
117
|
+
const json = JSON.stringify(value);
|
|
118
|
+
if (this.debug) this.log(`-> ${json.slice(0, 1000)}`);
|
|
119
|
+
this.child.stdin?.write(`${json}\n`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Last few KB of stderr — surfaced in error events. */
|
|
123
|
+
get stderrTail(): string {
|
|
124
|
+
return this.stderrBuf;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get isClosed(): boolean {
|
|
128
|
+
return this.closed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Terminate the process. Idempotent. */
|
|
132
|
+
async close(): Promise<void> {
|
|
133
|
+
if (this.closed) {
|
|
134
|
+
// Already exited — nothing to kill.
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
this.child.stdin?.end();
|
|
139
|
+
} catch {
|
|
140
|
+
// stdin may already be gone.
|
|
141
|
+
}
|
|
142
|
+
this.child.kill("SIGTERM");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build the `docker exec` argv that runs `cli …cliArgs` inside a task
|
|
148
|
+
* container. `-i` keeps stdin open for the bidirectional session.
|
|
149
|
+
*/
|
|
150
|
+
export function dockerExecArgs(
|
|
151
|
+
containerName: string,
|
|
152
|
+
cli: string,
|
|
153
|
+
cliArgs: string[],
|
|
154
|
+
/**
|
|
155
|
+
* Env var names to forward from the host-agent process into the
|
|
156
|
+
* `docker exec` (as `-e NAME`, value taken from the host-agent's own
|
|
157
|
+
* environment). Used to pass host-resident AI credentials — e.g.
|
|
158
|
+
* `CLAUDE_CODE_OAUTH_TOKEN` for headless Claude subscription auth — into
|
|
159
|
+
* the container without baking them into the image or sending them to the
|
|
160
|
+
* cloud (ADR-015). Only vars actually set on the host are forwarded.
|
|
161
|
+
*/
|
|
162
|
+
passEnv: string[] = [],
|
|
163
|
+
): { command: string; args: string[] } {
|
|
164
|
+
const envArgs: string[] = [];
|
|
165
|
+
for (const name of passEnv) {
|
|
166
|
+
if (process.env[name]) envArgs.push("-e", name);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
command: "docker",
|
|
170
|
+
args: ["exec", "-i", ...envArgs, containerName, cli, ...cliArgs],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent adapter registry (ADR-021).
|
|
3
|
+
*
|
|
4
|
+
* A small module-level registry so adding an agent kind is mechanical and
|
|
5
|
+
* needs no uai-core change: an adapter module calls `register()` at load,
|
|
6
|
+
* declaring its `kind`, display `label`, the models it can drive, an
|
|
7
|
+
* optional `defaultModel`, and a `create()` that builds an `AgentSession`.
|
|
8
|
+
*
|
|
9
|
+
* The host derives two things from the registry:
|
|
10
|
+
* - `factoryFor(kind)` — used by the real agent factory to construct a
|
|
11
|
+
* session for a roster entry by its `kind`.
|
|
12
|
+
* - `capabilities()` — the `agentKinds` slice of the `host.capabilities`
|
|
13
|
+
* frame, computed straight from the registered adapters.
|
|
14
|
+
*
|
|
15
|
+
* Registration is side-effect-on-import: importing `./claude` and `./codex`
|
|
16
|
+
* runs their `register()` calls (see `factory.ts`, which imports them).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { AgentSessionFactory } from "./types";
|
|
20
|
+
|
|
21
|
+
/** A registered agent adapter — metadata plus the session factory. */
|
|
22
|
+
export interface RegisteredAdapter {
|
|
23
|
+
/** Host-advertised agent kind ("claude" | "codex" | "gemini" | …). */
|
|
24
|
+
kind: string;
|
|
25
|
+
/** Display name for the cloud picker. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** Models this adapter can drive (CLI `--model` / `-c model=` values). */
|
|
28
|
+
supportedModels(): string[];
|
|
29
|
+
/** Preferred model when the user doesn't pick one. */
|
|
30
|
+
defaultModel?: string;
|
|
31
|
+
/** Reasoning levels this adapter can drive (CLI effort values). */
|
|
32
|
+
supportedEfforts(): string[];
|
|
33
|
+
/** Preferred effort when the user doesn't pick one. */
|
|
34
|
+
defaultEffort?: string;
|
|
35
|
+
/** Builds an AgentSession for a roster entry of this kind. */
|
|
36
|
+
create: AgentSessionFactory["create"];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One entry of the `host.capabilities` frame's `agentKinds`. */
|
|
40
|
+
export interface AgentKindCapability {
|
|
41
|
+
kind: string;
|
|
42
|
+
label: string;
|
|
43
|
+
supportedModels: string[];
|
|
44
|
+
defaultModel?: string;
|
|
45
|
+
supportedEfforts: string[];
|
|
46
|
+
defaultEffort?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const adapters = new Map<string, RegisteredAdapter>();
|
|
50
|
+
|
|
51
|
+
/** Listeners fired after any registry mutation (manual re-advertise hook). */
|
|
52
|
+
const changeListeners = new Set<() => void>();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register (or replace) an adapter for a kind. Called at module load by the
|
|
56
|
+
* adapter modules. Replacing is allowed so a later import wins deterministically.
|
|
57
|
+
*/
|
|
58
|
+
export function register(adapter: RegisteredAdapter): void {
|
|
59
|
+
adapters.set(adapter.kind, adapter);
|
|
60
|
+
for (const listener of changeListeners) listener();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Every registered adapter, in insertion order. */
|
|
64
|
+
export function list(): RegisteredAdapter[] {
|
|
65
|
+
return [...adapters.values()];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The session factory for a kind, or undefined if no adapter is registered. */
|
|
69
|
+
export function factoryFor(kind: string): AgentSessionFactory | undefined {
|
|
70
|
+
const adapter = adapters.get(kind);
|
|
71
|
+
return adapter ? { create: adapter.create } : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The `agentKinds` capability slice, derived from the registered adapters.
|
|
76
|
+
* Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
|
|
77
|
+
*/
|
|
78
|
+
export function capabilities(): AgentKindCapability[] {
|
|
79
|
+
return list().map((adapter) => {
|
|
80
|
+
const out: AgentKindCapability = {
|
|
81
|
+
kind: adapter.kind,
|
|
82
|
+
label: adapter.label,
|
|
83
|
+
supportedModels: adapter.supportedModels(),
|
|
84
|
+
supportedEfforts: adapter.supportedEfforts(),
|
|
85
|
+
};
|
|
86
|
+
if (adapter.defaultModel !== undefined) {
|
|
87
|
+
out.defaultModel = adapter.defaultModel;
|
|
88
|
+
}
|
|
89
|
+
if (adapter.defaultEffort !== undefined) {
|
|
90
|
+
out.defaultEffort = adapter.defaultEffort;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Subscribe to registry changes (adapter added/replaced). Returns an
|
|
98
|
+
* unsubscribe. Used to re-send the capability frame on change.
|
|
99
|
+
*/
|
|
100
|
+
export function onChange(listener: () => void): () => void {
|
|
101
|
+
changeListeners.add(listener);
|
|
102
|
+
return () => changeListeners.delete(listener);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Test/maintenance hook: drop all adapters and listeners. */
|
|
106
|
+
export function reset(): void {
|
|
107
|
+
adapters.clear();
|
|
108
|
+
changeListeners.clear();
|
|
109
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent abstraction.
|
|
3
|
+
*
|
|
4
|
+
* uai drives Claude Code and Codex in their structured modes (see
|
|
5
|
+
* docs/interaction-model.md). Both — plus a mock for testing — implement
|
|
6
|
+
* the same `AgentSession` interface, so the orchestrator and chat UI
|
|
7
|
+
* never branch on which CLI is behind a given agent.
|
|
8
|
+
*
|
|
9
|
+
* An "agent" is a roster entry on a project, not a hardcoded
|
|
10
|
+
* Claude/Codex pair. A project can run one agent or several; each has a
|
|
11
|
+
* stable `id` used for addressing (`@<id>`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
/** The host-advertised adapter kind ("claude" | "codex" | "gemini" | …). */
|
|
17
|
+
export const AgentKind = z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1)
|
|
20
|
+
.regex(/^[a-z0-9][a-z0-9_-]*$/, "kind must be lowercase letters, digits, _ or -");
|
|
21
|
+
export type AgentKind = z.infer<typeof AgentKind>;
|
|
22
|
+
|
|
23
|
+
/** A task agent (ADR-021/ADR-022) — materialised onto a task.
|
|
24
|
+
*
|
|
25
|
+
* `id` must be URL-safe (letters, digits, `_`, `-`) because it is the
|
|
26
|
+
* `@<id>` mention token. `kind` selects the adapter; `model` is passed
|
|
27
|
+
* through to the CLI. There is no `role` field. `initialPrompt` (if set)
|
|
28
|
+
* drives this agent's first turn at container-ready. */
|
|
29
|
+
export const RosterAgent = z.object({
|
|
30
|
+
id: z
|
|
31
|
+
.string()
|
|
32
|
+
.min(1)
|
|
33
|
+
.regex(/^[A-Za-z0-9_-]+$/, "id must be url-safe: letters, digits, _ or -"),
|
|
34
|
+
label: z.string().min(1), // display name
|
|
35
|
+
kind: AgentKind,
|
|
36
|
+
model: z.string().min(1).optional(),
|
|
37
|
+
effort: z.string().min(1).optional(), // CLI reasoning level, passed through
|
|
38
|
+
defaultPrompt: z.string().optional(), // persona
|
|
39
|
+
initialPrompt: z.string().optional(), // first-turn instructions, this task only
|
|
40
|
+
});
|
|
41
|
+
export type RosterAgent = z.infer<typeof RosterAgent>;
|
|
42
|
+
|
|
43
|
+
/** A task's agents — distinct ids so a mention routes unambiguously. May be
|
|
44
|
+
* empty (a scratchpad task with no crew). */
|
|
45
|
+
export const Roster = z
|
|
46
|
+
.array(RosterAgent)
|
|
47
|
+
.refine((agents) => new Set(agents.map((a) => a.id)).size === agents.length, {
|
|
48
|
+
message: "agent ids must be unique within a roster",
|
|
49
|
+
});
|
|
50
|
+
export type Roster = z.infer<typeof Roster>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a task's `agents` JSON into a validated Roster. Falls back to an
|
|
54
|
+
* empty roster if malformed.
|
|
55
|
+
*/
|
|
56
|
+
export function parseRoster(raw: string): Roster {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = Roster.safeParse(JSON.parse(raw));
|
|
59
|
+
return parsed.success ? parsed.data : [];
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Events an agent session emits as it works. The orchestrator persists
|
|
67
|
+
// these to `uai_messages` and streams them to the browser.
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
export type AgentEvent =
|
|
71
|
+
/** A chunk of streaming assistant text. Appended to the in-progress message. */
|
|
72
|
+
| { type: "message_delta"; text: string }
|
|
73
|
+
/** The assistant message finished. `text` is the full final text. */
|
|
74
|
+
| { type: "message_complete"; text: string }
|
|
75
|
+
/** The agent invoked a tool / ran a command. Rendered as a card. */
|
|
76
|
+
| { type: "tool_call"; id: string; title: string; detail: string }
|
|
77
|
+
/** The agent needs approval before proceeding. */
|
|
78
|
+
| { type: "permission_request"; id: string; title: string; detail: string }
|
|
79
|
+
/** The agent addressed another agent — uai routes this as a peer message. */
|
|
80
|
+
| { type: "peer_message"; toAgentId: string; text: string }
|
|
81
|
+
/** The turn (one request → response cycle) is done; agent is idle. */
|
|
82
|
+
| { type: "turn_complete" }
|
|
83
|
+
/** A recoverable error surfaced by the agent. */
|
|
84
|
+
| { type: "error"; message: string }
|
|
85
|
+
/** The underlying process exited. */
|
|
86
|
+
| { type: "exit"; code: number };
|
|
87
|
+
|
|
88
|
+
export type AgentEventHandler = (event: AgentEvent) => void;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A live session with one agent. Implementations: ClaudeSession
|
|
92
|
+
* (stream-json), CodexSession (codex app-server), MockAgentSession.
|
|
93
|
+
*/
|
|
94
|
+
export interface AgentSession {
|
|
95
|
+
/** The roster id this session is for. */
|
|
96
|
+
readonly agentId: string;
|
|
97
|
+
/** Which agent kind backs it ("claude" | "codex" | …). */
|
|
98
|
+
readonly kind: AgentKind;
|
|
99
|
+
|
|
100
|
+
/** Send a user (or routed peer) message into the agent. */
|
|
101
|
+
send(text: string): Promise<void>;
|
|
102
|
+
|
|
103
|
+
/** Interrupt the agent's current turn (ESC). Idempotent / safe when idle. */
|
|
104
|
+
interrupt(): Promise<void>;
|
|
105
|
+
|
|
106
|
+
/** Resolve a pending permission request. */
|
|
107
|
+
resolvePermission(
|
|
108
|
+
requestId: string,
|
|
109
|
+
decision: "accept" | "decline",
|
|
110
|
+
): Promise<void>;
|
|
111
|
+
|
|
112
|
+
/** Subscribe to events. Returns an unsubscribe function. */
|
|
113
|
+
onEvent(handler: AgentEventHandler): () => void;
|
|
114
|
+
|
|
115
|
+
/** Tear the session down. Idempotent. */
|
|
116
|
+
close(): Promise<void>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Constructs an AgentSession for a roster entry. The orchestrator is
|
|
121
|
+
* handed one of these; swapping mock ⇄ real adapters is changing the
|
|
122
|
+
* factory, nothing else.
|
|
123
|
+
*/
|
|
124
|
+
export interface AgentSessionFactory {
|
|
125
|
+
create(args: {
|
|
126
|
+
taskId: string;
|
|
127
|
+
agent: RosterAgent;
|
|
128
|
+
/** Container the task runs in (real adapters `docker exec` into it). */
|
|
129
|
+
containerName: string;
|
|
130
|
+
/** Initial briefing — project.defaultPrompt — sent on session start. */
|
|
131
|
+
systemPreamble: string;
|
|
132
|
+
}): Promise<AgentSession>;
|
|
133
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat attachments live on the host, inside the task workspace (ADR-015: the
|
|
3
|
+
* cloud stores no blobs). They land under `<workspace>/.uai/attachments/` —
|
|
4
|
+
* inside the workspace so the container (mounted at `/workspace`) reads them
|
|
5
|
+
* directly, and under `.uai/` so they sit outside the per-project worktrees and
|
|
6
|
+
* never get committed. The cloud relays bytes in/out; it never persists them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { basename, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { taskWorkspaceDir } from "./env";
|
|
13
|
+
|
|
14
|
+
/** Host directory holding a task's attachments. */
|
|
15
|
+
export function attachmentsDir(taskId: string): string {
|
|
16
|
+
return resolve(taskWorkspaceDir(taskId), ".uai", "attachments");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The path an in-container agent reads (workspace is mounted at /workspace). */
|
|
20
|
+
export function containerAttachmentPath(filename: string): string {
|
|
21
|
+
return `/workspace/.uai/attachments/${basename(filename)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Resolve + harden a filename to a path inside the task's attachments dir. */
|
|
25
|
+
function safePath(taskId: string, filename: string): string {
|
|
26
|
+
const dir = attachmentsDir(taskId);
|
|
27
|
+
const full = resolve(dir, basename(filename));
|
|
28
|
+
if (full !== dir && !full.startsWith(dir + "/")) {
|
|
29
|
+
throw new Error("invalid attachment filename");
|
|
30
|
+
}
|
|
31
|
+
return full;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeAttachment(
|
|
35
|
+
taskId: string,
|
|
36
|
+
filename: string,
|
|
37
|
+
bytes: Buffer,
|
|
38
|
+
): void {
|
|
39
|
+
mkdirSync(attachmentsDir(taskId), { recursive: true });
|
|
40
|
+
writeFileSync(safePath(taskId, filename), bytes);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readAttachment(taskId: string, filename: string): Buffer | null {
|
|
44
|
+
const full = safePath(taskId, filename);
|
|
45
|
+
return existsSync(full) ? readFileSync(full) : null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cloud (WSS bridge) connection state for the local UI (ADR-028).
|
|
3
|
+
*
|
|
4
|
+
* The bridge client in `src/main.ts` is the single writer; the local
|
|
5
|
+
* `/api/cloud` handler is a reader. Module-level singleton — there is exactly
|
|
6
|
+
* one bridge connection per host process. Never persisted.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type CloudConnectionState =
|
|
10
|
+
| "connected"
|
|
11
|
+
| "reconnecting"
|
|
12
|
+
| "disconnected";
|
|
13
|
+
|
|
14
|
+
export interface CloudState {
|
|
15
|
+
state: CloudConnectionState;
|
|
16
|
+
/** Epoch ms of the last successful connect, or null if never connected. */
|
|
17
|
+
lastConnectedAt: number | null;
|
|
18
|
+
/** Count of reconnect attempts since process start. */
|
|
19
|
+
reconnectCount: number;
|
|
20
|
+
/** Most recent connection error message, or null. */
|
|
21
|
+
lastError: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const state: CloudState = {
|
|
25
|
+
state: "disconnected",
|
|
26
|
+
lastConnectedAt: null,
|
|
27
|
+
reconnectCount: 0,
|
|
28
|
+
lastError: null,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Snapshot for `/api/cloud`. */
|
|
32
|
+
export function getCloudState(): CloudState {
|
|
33
|
+
return { ...state };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function markConnected(): void {
|
|
37
|
+
state.state = "connected";
|
|
38
|
+
state.lastConnectedAt = Date.now();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The socket dropped and we will retry. Increments the reconnect counter. */
|
|
42
|
+
export function markReconnecting(error?: string | null): void {
|
|
43
|
+
state.state = "reconnecting";
|
|
44
|
+
state.reconnectCount += 1;
|
|
45
|
+
if (error !== undefined) state.lastError = error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Stopped for good (shutdown or fatal auth failure) — no retry. */
|
|
49
|
+
export function markDisconnected(error?: string | null): void {
|
|
50
|
+
state.state = "disconnected";
|
|
51
|
+
if (error !== undefined) state.lastError = error;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function markCloudError(message: string): void {
|
|
55
|
+
state.lastError = message;
|
|
56
|
+
}
|