@aos-harness/adapter-shared 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@aos-harness/adapter-shared",
3
+ "version": "0.4.1",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./*": "./src/*"
8
+ },
9
+ "files": ["src/"],
10
+ "dependencies": {
11
+ "@aos-harness/runtime": "0.4.1",
12
+ "js-yaml": "^4.1.0"
13
+ },
14
+ "devDependencies": {
15
+ "@types/js-yaml": "^4.0.9",
16
+ "@types/bun": "latest",
17
+ "typescript": "^5.8.0"
18
+ }
19
+ }
@@ -0,0 +1,331 @@
1
+ // ── BaseAgentRuntime (L1) ─────────────────────────────────────────
2
+ // Abstract subprocess lifecycle for CLI-based adapters.
3
+ // Concrete implementations override CLI-specific methods.
4
+
5
+ import { spawn, type ChildProcess } from "node:child_process";
6
+ import { existsSync, mkdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import type {
9
+ AgentRuntimeAdapter,
10
+ AgentHandle,
11
+ AgentResponse,
12
+ AgentConfig,
13
+ ChildAgentConfig,
14
+ MessageOpts,
15
+ AuthMode,
16
+ ModelCost,
17
+ ModelTier,
18
+ ThinkingMode,
19
+ ContextUsage,
20
+ } from "@aos-harness/runtime/types";
21
+ import type { HandleState, ParsedEvent, StdoutFormat, ModelInfo } from "./types";
22
+ import type { BaseEventBus } from "./base-event-bus";
23
+
24
+ export abstract class BaseAgentRuntime implements AgentRuntimeAdapter {
25
+ protected handles = new Map<string, HandleState>();
26
+ protected activeProcesses = new Set<ChildProcess>();
27
+ protected orchestratorPrompt: string | undefined;
28
+ protected eventBus: BaseEventBus;
29
+ protected modelOverrides: Partial<Record<ModelTier, string>> = {};
30
+ private cachedModels: ModelInfo[] | null = null;
31
+ private cleanupRegistered = false;
32
+
33
+ constructor(eventBus: BaseEventBus, modelOverrides?: Partial<Record<ModelTier, string>>) {
34
+ this.eventBus = eventBus;
35
+ if (modelOverrides) this.modelOverrides = modelOverrides;
36
+ this.registerCleanup();
37
+ }
38
+
39
+ private registerCleanup(): void {
40
+ if (this.cleanupRegistered) return;
41
+ this.cleanupRegistered = true;
42
+
43
+ const cleanup = () => {
44
+ for (const proc of this.activeProcesses) {
45
+ try { proc.kill("SIGTERM"); } catch {}
46
+ }
47
+ };
48
+
49
+ process.on("beforeExit", cleanup);
50
+ process.on("SIGTERM", () => { cleanup(); process.exit(143); });
51
+ process.on("SIGINT", () => { cleanup(); process.exit(130); });
52
+ }
53
+
54
+ // ── Abstract methods ───────────────────────────────────────────
55
+
56
+ abstract cliBinary(): string;
57
+ abstract stdoutFormat(): StdoutFormat;
58
+ abstract buildArgs(state: HandleState, message: string, isFirstCall: boolean, opts?: MessageOpts): string[];
59
+ abstract parseEventLine(line: string): ParsedEvent | null;
60
+ abstract buildSubprocessEnv(): Record<string, string>;
61
+ abstract discoverModels(): Promise<ModelInfo[]>;
62
+ abstract defaultModelMap(): Record<ModelTier, string>;
63
+ abstract getAuthMode(): AuthMode;
64
+ abstract getModelCost(tier: ModelTier): ModelCost;
65
+
66
+ // ── Model resolution ───────────────────────────────────────────
67
+
68
+ resolveModelId(tier: ModelTier): string {
69
+ if (this.modelOverrides[tier]) return this.modelOverrides[tier]!;
70
+ const envKeys: Record<ModelTier, string> = {
71
+ economy: "AOS_MODEL_ECONOMY",
72
+ standard: "AOS_MODEL_STANDARD",
73
+ premium: "AOS_MODEL_PREMIUM",
74
+ };
75
+ const envVal = process.env[envKeys[tier]];
76
+ if (envVal) return envVal;
77
+ return this.defaultModelMap()[tier];
78
+ }
79
+
80
+ async getAvailableModels(): Promise<ModelInfo[]> {
81
+ if (this.cachedModels) return this.cachedModels;
82
+ try {
83
+ this.cachedModels = await this.discoverModels();
84
+ } catch (err: any) {
85
+ console.warn(`Model discovery failed for ${this.cliBinary()}: ${err.message}. Using default models.`);
86
+ const defaults = this.defaultModelMap();
87
+ this.cachedModels = Object.entries(defaults).map(([_tier, id]) => ({
88
+ id, name: id, contextWindow: 200_000, provider: this.cliBinary(),
89
+ }));
90
+ }
91
+ return this.cachedModels;
92
+ }
93
+
94
+ // ── AgentRuntimeAdapter ────────────────────────────────────────
95
+
96
+ async spawnAgent(config: AgentConfig, sessionId: string): Promise<AgentHandle> {
97
+ const sessionDir = join(".aos", "sessions", sessionId);
98
+ mkdirSync(sessionDir, { recursive: true });
99
+ const sessionFile = join(sessionDir, `${config.id}.jsonl`);
100
+
101
+ const handle: AgentHandle = {
102
+ id: `${sessionId}:${config.id}`,
103
+ agentId: config.id,
104
+ sessionId,
105
+ };
106
+
107
+ this.handles.set(handle.id, {
108
+ config,
109
+ sessionFile,
110
+ contextFiles: [],
111
+ modelConfig: { tier: config.model.tier, thinking: config.model.thinking },
112
+ lastContextTokens: 0,
113
+ });
114
+
115
+ return handle;
116
+ }
117
+
118
+ async sendMessage(handle: AgentHandle, message: string, opts?: MessageOpts): Promise<AgentResponse> {
119
+ return this.sendMessageWithRetry(handle, message, opts);
120
+ }
121
+
122
+ async sendMessageWithRetry(
123
+ handle: AgentHandle, message: string, opts?: MessageOpts,
124
+ maxRetries: number = 2, backoff: "exponential" | "linear" = "exponential",
125
+ timeoutMs: number = 120000,
126
+ ): Promise<AgentResponse> {
127
+ let lastResponse: AgentResponse | null = null;
128
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
129
+ const response = await this.sendMessageOnce(handle, message, opts, timeoutMs);
130
+ if (response.status === "success") return response;
131
+ lastResponse = response;
132
+ if (response.status === "aborted") return response;
133
+ if (attempt < maxRetries) {
134
+ const delayMs = backoff === "exponential" ? 1000 * Math.pow(2, attempt) : 1000 * (attempt + 1);
135
+ await new Promise((r) => setTimeout(r, delayMs));
136
+ }
137
+ }
138
+ return lastResponse!;
139
+ }
140
+
141
+ private async sendMessageOnce(
142
+ handle: AgentHandle, message: string, opts?: MessageOpts, timeoutMs: number = 120000,
143
+ ): Promise<AgentResponse> {
144
+ const state = this.handles.get(handle.id);
145
+ if (!state) {
146
+ return { text: "", tokensIn: 0, tokensOut: 0, cost: 0, contextTokens: 0, model: "unknown", status: "failed", error: `No state found for handle ${handle.id}` };
147
+ }
148
+
149
+ const isFirstCall = !existsSync(state.sessionFile);
150
+ const args = this.buildArgs(state, message, isFirstCall, opts);
151
+ const format = this.stdoutFormat();
152
+
153
+ return new Promise<AgentResponse>((resolve) => {
154
+ const timeoutController = new AbortController();
155
+ const timeoutId = setTimeout(() => { timeoutController.abort(); }, timeoutMs);
156
+
157
+ const proc = spawn(this.cliBinary(), args, {
158
+ shell: false, stdio: ["ignore", "pipe", "pipe"], env: this.buildSubprocessEnv(),
159
+ });
160
+ this.activeProcesses.add(proc);
161
+
162
+ let buffer = "";
163
+ let stderr = "";
164
+ let accumulatedText = "";
165
+ let finalResponse = "";
166
+ let tokensIn = 0, tokensOut = 0, cost = 0, contextTokens = 0;
167
+ let model = this.resolveModelId(state.modelConfig.tier);
168
+ let wasAborted = false;
169
+
170
+ const processEvent = (event: ParsedEvent) => {
171
+ switch (event.type) {
172
+ case "text_delta":
173
+ accumulatedText += event.text;
174
+ opts?.onStream?.(accumulatedText);
175
+ break;
176
+ case "message_end":
177
+ finalResponse = event.text;
178
+ tokensIn += event.tokensIn;
179
+ tokensOut += event.tokensOut;
180
+ cost += event.cost;
181
+ contextTokens = event.contextTokens;
182
+ if (event.model) model = event.model;
183
+ break;
184
+ case "tool_call":
185
+ this.eventBus.fireToolCall(event.name, event.input);
186
+ break;
187
+ case "tool_result":
188
+ this.eventBus.fireToolResult(event.name, event.input, event.result);
189
+ break;
190
+ case "ignored":
191
+ break;
192
+ }
193
+ };
194
+
195
+ const processLine = (line: string) => {
196
+ if (!line.trim()) return;
197
+ let jsonLine = line;
198
+ if (format === "sse") {
199
+ if (!line.startsWith("data:")) return;
200
+ jsonLine = line.slice(5).trim();
201
+ if (jsonLine === "[DONE]") return;
202
+ }
203
+ const event = this.parseEventLine(jsonLine);
204
+ if (event) processEvent(event);
205
+ };
206
+
207
+ proc.stdout!.on("data", (data: Buffer) => {
208
+ buffer += data.toString();
209
+ if (format === "ndjson" || format === "sse") {
210
+ const lines = buffer.split("\n");
211
+ buffer = lines.pop() || "";
212
+ for (const line of lines) processLine(line);
213
+ } else if (format === "chunked-json") {
214
+ let braceDepth = 0;
215
+ let start = -1;
216
+ for (let i = 0; i < buffer.length; i++) {
217
+ if (buffer[i] === "{") {
218
+ if (braceDepth === 0) start = i;
219
+ braceDepth++;
220
+ } else if (buffer[i] === "}") {
221
+ braceDepth--;
222
+ if (braceDepth === 0 && start >= 0) {
223
+ processLine(buffer.slice(start, i + 1));
224
+ buffer = buffer.slice(i + 1);
225
+ i = -1;
226
+ start = -1;
227
+ }
228
+ }
229
+ }
230
+ }
231
+ });
232
+
233
+ proc.stderr!.on("data", (data: Buffer) => { stderr += data.toString(); });
234
+
235
+ proc.on("close", (code: number | null) => {
236
+ clearTimeout(timeoutId);
237
+ if (buffer.trim()) processLine(buffer);
238
+ this.activeProcesses.delete(proc);
239
+ if (contextTokens > 0) state.lastContextTokens = contextTokens;
240
+ if (tokensIn > 0 || tokensOut > 0 || cost > 0) {
241
+ this.eventBus.fireMessageEnd({ cost, tokens: tokensIn + tokensOut });
242
+ }
243
+ if (wasAborted) {
244
+ resolve({ text: accumulatedText, tokensIn, tokensOut, cost, contextTokens, model, status: "aborted", error: "Agent call was aborted" });
245
+ return;
246
+ }
247
+ if (code !== 0 && !finalResponse && !accumulatedText) {
248
+ resolve({ text: "", tokensIn, tokensOut, cost, contextTokens, model, status: "failed", error: `Process exited with code ${code}: ${stderr.slice(0, 500)}` });
249
+ return;
250
+ }
251
+ resolve({ text: finalResponse || accumulatedText, tokensIn, tokensOut, cost, contextTokens, model, status: "success" });
252
+ });
253
+
254
+ proc.on("error", (err: Error) => {
255
+ clearTimeout(timeoutId);
256
+ this.activeProcesses.delete(proc);
257
+ resolve({ text: "", tokensIn: 0, tokensOut: 0, cost: 0, contextTokens: 0, model, status: "failed", error: `Failed to spawn ${this.cliBinary()}: ${err.message}` });
258
+ });
259
+
260
+ timeoutController.signal.addEventListener("abort", () => {
261
+ wasAborted = true;
262
+ proc.kill("SIGTERM");
263
+ setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 5000);
264
+ clearTimeout(timeoutId);
265
+ this.activeProcesses.delete(proc);
266
+ resolve({ text: accumulatedText, tokensIn, tokensOut, cost, contextTokens, model, status: "failed", error: `Agent timed out after ${Math.round(timeoutMs / 1000)}s` });
267
+ }, { once: true });
268
+
269
+ if (opts?.signal) {
270
+ const killProc = () => {
271
+ wasAborted = true;
272
+ proc.kill("SIGTERM");
273
+ setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 5000);
274
+ };
275
+ if (opts.signal.aborted) killProc();
276
+ else opts.signal.addEventListener("abort", killProc, { once: true });
277
+ }
278
+ });
279
+ }
280
+
281
+ async destroyAgent(handle: AgentHandle): Promise<void> {
282
+ this.handles.delete(handle.id);
283
+ }
284
+
285
+ setOrchestratorPrompt(prompt: string): void {
286
+ this.orchestratorPrompt = prompt;
287
+ }
288
+
289
+ async injectContext(handle: AgentHandle, files: string[]): Promise<void> {
290
+ const state = this.handles.get(handle.id);
291
+ if (state) state.contextFiles = files;
292
+ }
293
+
294
+ getContextUsage(handle: AgentHandle): ContextUsage {
295
+ const state = this.handles.get(handle.id);
296
+ const tokens = state?.lastContextTokens || 0;
297
+ return { tokens, percent: (tokens / 200_000) * 100 };
298
+ }
299
+
300
+ setModel(handle: AgentHandle, modelConfig: { tier: ModelTier; thinking: ThinkingMode }): void {
301
+ const state = this.handles.get(handle.id);
302
+ if (state) state.modelConfig = modelConfig;
303
+ }
304
+
305
+ abort(): void {
306
+ for (const proc of this.activeProcesses) {
307
+ proc.kill("SIGTERM");
308
+ setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 5000);
309
+ }
310
+ this.activeProcesses.clear();
311
+ }
312
+
313
+ async spawnSubAgent(parentId: string, config: ChildAgentConfig, sessionId: string): Promise<AgentHandle> {
314
+ const agentConfig = {
315
+ ...config,
316
+ model: config.model ?? { tier: "standard" as ModelTier, thinking: "on" as ThinkingMode },
317
+ } as AgentConfig;
318
+ const handle = await this.spawnAgent(agentConfig, sessionId);
319
+ handle.parentAgentId = parentId;
320
+ return handle;
321
+ }
322
+
323
+ async destroySubAgent(_parentId: string, childId: string): Promise<void> {
324
+ for (const [key] of this.handles) {
325
+ if (key.endsWith(`:${childId}`)) {
326
+ this.handles.delete(key);
327
+ return;
328
+ }
329
+ }
330
+ }
331
+ }
@@ -0,0 +1,133 @@
1
+ // ── BaseEventBus (L2) ─────────────────────────────────────────────
2
+ // Concrete handler storage + sequential async dispatch.
3
+ // All fire*() methods serialize through a queue to prevent interleaving.
4
+
5
+ import type { EventBusAdapter } from "@aos-harness/runtime/types";
6
+
7
+ export class BaseEventBus implements EventBusAdapter {
8
+ private handlers: {
9
+ sessionStart: (() => Promise<void>) | null;
10
+ sessionShutdown: (() => Promise<void>) | null;
11
+ beforeAgentStart: ((prompt: string) => Promise<{ systemPrompt?: string }>) | null;
12
+ agentEnd: (() => Promise<void>) | null;
13
+ toolCall: ((toolName: string, input: unknown) => Promise<{ block?: boolean }>) | null;
14
+ toolResult: ((toolName: string, input: unknown, result: unknown) => Promise<void>) | null;
15
+ messageEnd: ((usage: { cost: number; tokens: number }) => Promise<void>) | null;
16
+ compaction: (() => Promise<void>) | null;
17
+ } = {
18
+ sessionStart: null,
19
+ sessionShutdown: null,
20
+ beforeAgentStart: null,
21
+ agentEnd: null,
22
+ toolCall: null,
23
+ toolResult: null,
24
+ messageEnd: null,
25
+ compaction: null,
26
+ };
27
+
28
+ // Sequential dispatch queue
29
+ private queue: Promise<void> = Promise.resolve();
30
+
31
+ private enqueue<T>(fn: () => Promise<T>): Promise<T> {
32
+ let resolve!: (value: T) => void;
33
+ let reject!: (err: unknown) => void;
34
+ const result = new Promise<T>((res, rej) => {
35
+ resolve = res;
36
+ reject = rej;
37
+ });
38
+ this.queue = this.queue.then(() => fn().then(resolve, reject));
39
+ return result;
40
+ }
41
+
42
+ // ── Registration (EventBusAdapter interface) ───────────────────
43
+
44
+ onSessionStart(handler: () => Promise<void>): void {
45
+ this.handlers.sessionStart = handler;
46
+ }
47
+
48
+ onSessionShutdown(handler: () => Promise<void>): void {
49
+ this.handlers.sessionShutdown = handler;
50
+ }
51
+
52
+ onBeforeAgentStart(handler: (prompt: string) => Promise<{ systemPrompt?: string }>): void {
53
+ this.handlers.beforeAgentStart = handler;
54
+ }
55
+
56
+ onAgentEnd(handler: () => Promise<void>): void {
57
+ this.handlers.agentEnd = handler;
58
+ }
59
+
60
+ onToolCall(handler: (toolName: string, input: unknown) => Promise<{ block?: boolean }>): void {
61
+ this.handlers.toolCall = handler;
62
+ }
63
+
64
+ onToolResult(handler: (toolName: string, input: unknown, result: unknown) => Promise<void>): void {
65
+ this.handlers.toolResult = handler;
66
+ }
67
+
68
+ onMessageEnd(handler: (usage: { cost: number; tokens: number }) => Promise<void>): void {
69
+ this.handlers.messageEnd = handler;
70
+ }
71
+
72
+ onCompaction(handler: () => Promise<void>): void {
73
+ this.handlers.compaction = handler;
74
+ }
75
+
76
+ // ── Fire methods (called by BaseAgentRuntime) ──────────────────
77
+
78
+ fireSessionStart(): Promise<void> {
79
+ return this.enqueue(async () => {
80
+ if (this.handlers.sessionStart) await this.handlers.sessionStart();
81
+ });
82
+ }
83
+
84
+ fireSessionShutdown(): Promise<void> {
85
+ return this.enqueue(async () => {
86
+ if (this.handlers.sessionShutdown) await this.handlers.sessionShutdown();
87
+ });
88
+ }
89
+
90
+ fireBeforeAgentStart(prompt: string): Promise<{ systemPrompt?: string }> {
91
+ return this.enqueue(async () => {
92
+ if (this.handlers.beforeAgentStart) {
93
+ return await this.handlers.beforeAgentStart(prompt);
94
+ }
95
+ return {};
96
+ });
97
+ }
98
+
99
+ fireAgentEnd(): Promise<void> {
100
+ return this.enqueue(async () => {
101
+ if (this.handlers.agentEnd) await this.handlers.agentEnd();
102
+ });
103
+ }
104
+
105
+ fireToolCall(toolName: string, input: unknown): Promise<{ block?: boolean }> {
106
+ return this.enqueue(async () => {
107
+ if (this.handlers.toolCall) {
108
+ return await this.handlers.toolCall(toolName, input);
109
+ }
110
+ return { block: false };
111
+ });
112
+ }
113
+
114
+ fireToolResult(toolName: string, input: unknown, result: unknown): Promise<void> {
115
+ return this.enqueue(async () => {
116
+ if (this.handlers.toolResult) {
117
+ await this.handlers.toolResult(toolName, input, result);
118
+ }
119
+ });
120
+ }
121
+
122
+ fireMessageEnd(usage: { cost: number; tokens: number }): Promise<void> {
123
+ return this.enqueue(async () => {
124
+ if (this.handlers.messageEnd) await this.handlers.messageEnd(usage);
125
+ });
126
+ }
127
+
128
+ fireCompaction(): Promise<void> {
129
+ return this.enqueue(async () => {
130
+ if (this.handlers.compaction) await this.handlers.compaction();
131
+ });
132
+ }
133
+ }
@@ -0,0 +1,392 @@
1
+ // ── BaseWorkflow (L4) ────────────────────────────────────────────
2
+ // Parallel dispatch, file operations, state persistence, artifacts,
3
+ // code execution, and skill invocation. CLI-agnostic.
4
+
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { join, dirname, resolve } from "node:path";
8
+ import * as yaml from "js-yaml";
9
+ import type {
10
+ WorkflowAdapter,
11
+ AgentHandle,
12
+ AgentResponse,
13
+ MessageOpts,
14
+ ArtifactManifest,
15
+ LoadedArtifact,
16
+ ExecuteCodeOpts,
17
+ ExecutionResult,
18
+ SkillInput,
19
+ SkillResult,
20
+ ReviewResult,
21
+ } from "@aos-harness/runtime/types";
22
+ import { UnsupportedError } from "@aos-harness/runtime/types";
23
+
24
+ // Minimal interface for the agent runtime dependency
25
+ interface AgentMessageSender {
26
+ sendMessage(handle: AgentHandle, message: string, opts?: MessageOpts): Promise<AgentResponse>;
27
+ }
28
+
29
+ export class BaseWorkflow implements WorkflowAdapter {
30
+ private agentRuntime: AgentMessageSender;
31
+ private projectRoot: string;
32
+
33
+ constructor(agentRuntime: AgentMessageSender, projectRoot: string = process.cwd()) {
34
+ this.agentRuntime = agentRuntime;
35
+ this.projectRoot = resolve(projectRoot);
36
+ }
37
+
38
+ private validatePath(filePath: string): string {
39
+ const resolved = resolve(filePath);
40
+ if (!resolved.startsWith(this.projectRoot)) {
41
+ throw new Error(`Path "${filePath}" is outside the project directory`);
42
+ }
43
+ return resolved;
44
+ }
45
+
46
+ private validateStateKey(key: string): void {
47
+ if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
48
+ throw new Error(`Invalid state key: "${key}" — must be alphanumeric with hyphens/underscores`);
49
+ }
50
+ }
51
+
52
+ async dispatchParallel(
53
+ handles: AgentHandle[],
54
+ message: string,
55
+ opts?: { signal?: AbortSignal; onStream?: (agentId: string, partial: string) => void },
56
+ ): Promise<AgentResponse[]> {
57
+ const tasks = handles.map((handle) =>
58
+ this.agentRuntime.sendMessage(handle, message, {
59
+ signal: opts?.signal,
60
+ onStream: opts?.onStream
61
+ ? (partial: string) => opts.onStream!(handle.agentId, partial)
62
+ : undefined,
63
+ }),
64
+ );
65
+
66
+ const results = await Promise.allSettled(tasks);
67
+
68
+ return results.map((result): AgentResponse => {
69
+ if (result.status === "fulfilled") {
70
+ return result.value;
71
+ }
72
+ const err = result.reason instanceof Error ? result.reason.message : String(result.reason);
73
+ return {
74
+ text: "",
75
+ tokensIn: 0,
76
+ tokensOut: 0,
77
+ cost: 0,
78
+ contextTokens: 0,
79
+ model: "",
80
+ status: "failed",
81
+ error: err,
82
+ };
83
+ });
84
+ }
85
+
86
+ async isolateWorkspace(): Promise<{ path: string; cleanup: () => Promise<void> }> {
87
+ const id = `aos-worktree-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
88
+ const worktreePath = join(".aos", "worktrees", id);
89
+
90
+ await new Promise<void>((resolve, reject) => {
91
+ const proc = spawn("git", ["worktree", "add", "--detach", worktreePath], {
92
+ shell: false,
93
+ stdio: ["ignore", "pipe", "pipe"],
94
+ });
95
+ let stderr = "";
96
+ proc.stderr?.on("data", (chunk: Buffer) => {
97
+ stderr += chunk.toString();
98
+ });
99
+ proc.on("close", (code) => {
100
+ if (code === 0) resolve();
101
+ else reject(new Error(`git worktree add failed (exit ${code}): ${stderr.trim()}`));
102
+ });
103
+ });
104
+
105
+ const cleanup = async (): Promise<void> => {
106
+ await new Promise<void>((resolve, reject) => {
107
+ const proc = spawn("git", ["worktree", "remove", "--force", worktreePath], {
108
+ shell: false,
109
+ stdio: ["ignore", "pipe", "pipe"],
110
+ });
111
+ let stderr = "";
112
+ proc.stderr?.on("data", (chunk: Buffer) => {
113
+ stderr += chunk.toString();
114
+ });
115
+ proc.on("close", (code) => {
116
+ if (code === 0) resolve();
117
+ else reject(new Error(`git worktree remove failed (exit ${code}): ${stderr.trim()}`));
118
+ });
119
+ });
120
+ };
121
+
122
+ return { path: worktreePath, cleanup };
123
+ }
124
+
125
+ async writeFile(path: string, content: string): Promise<void> {
126
+ const safe = this.validatePath(path);
127
+ const dir = dirname(safe);
128
+ if (!existsSync(dir)) {
129
+ mkdirSync(dir, { recursive: true });
130
+ }
131
+ writeFileSync(safe, content, "utf-8");
132
+ }
133
+
134
+ async readFile(path: string): Promise<string> {
135
+ const safe = this.validatePath(path);
136
+ if (!existsSync(safe)) {
137
+ throw new Error(`File not found: ${path}`);
138
+ }
139
+ return readFileSync(safe, "utf-8");
140
+ }
141
+
142
+ private static ALLOWED_EDITORS = new Set(["code", "vim", "nvim", "nano", "emacs", "subl", "mate", "open"]);
143
+
144
+ async openInEditor(path: string, editor: string): Promise<void> {
145
+ const safePath = this.validatePath(path);
146
+ const editorName = editor.split("/").pop() ?? editor;
147
+ if (!BaseWorkflow.ALLOWED_EDITORS.has(editorName)) {
148
+ throw new Error(`Editor "${editor}" is not in the allowed list: ${[...BaseWorkflow.ALLOWED_EDITORS].join(", ")}`);
149
+ }
150
+ spawn(editor, [safePath], { detached: true, stdio: "ignore" }).unref();
151
+ }
152
+
153
+ async persistState(key: string, value: unknown): Promise<void> {
154
+ this.validateStateKey(key);
155
+ const stateDir = join(this.projectRoot, ".aos", "state");
156
+ if (!existsSync(stateDir)) {
157
+ mkdirSync(stateDir, { recursive: true });
158
+ }
159
+ const filePath = join(stateDir, `${key}.json`);
160
+ writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
161
+ }
162
+
163
+ async loadState(key: string): Promise<unknown> {
164
+ this.validateStateKey(key);
165
+ const filePath = join(this.projectRoot, ".aos", "state", `${key}.json`);
166
+ if (!existsSync(filePath)) {
167
+ return null;
168
+ }
169
+ const raw = readFileSync(filePath, "utf-8");
170
+ return JSON.parse(raw);
171
+ }
172
+
173
+ async createArtifact(artifact: ArtifactManifest, content: string): Promise<void> {
174
+ await this.writeFile(artifact.content_path, content);
175
+ const manifestPath = artifact.content_path.replace(/\.[^.]+$/, ".artifact.yaml");
176
+ await this.writeFile(manifestPath, yaml.dump(artifact));
177
+ }
178
+
179
+ async loadArtifact(artifactId: string, sessionDir: string): Promise<LoadedArtifact> {
180
+ const manifestPath = join(sessionDir, "artifacts", `${artifactId}.artifact.yaml`);
181
+ const manifestYaml = await this.readFile(manifestPath);
182
+ const manifest = yaml.load(manifestYaml, { schema: yaml.JSON_SCHEMA }) as ArtifactManifest;
183
+ const content = await this.readFile(manifest.content_path);
184
+ return { manifest, content };
185
+ }
186
+
187
+ async submitForReview(
188
+ artifact: LoadedArtifact,
189
+ reviewer: AgentHandle,
190
+ reviewPrompt?: string,
191
+ ): Promise<ReviewResult> {
192
+ const prompt =
193
+ reviewPrompt ||
194
+ `Review the following artifact and provide your assessment:
195
+
196
+ ## Artifact: ${artifact.manifest.id}
197
+ - Produced by: ${artifact.manifest.produced_by.join(", ")}
198
+ - Format: ${artifact.manifest.format}
199
+
200
+ Respond with:
201
+ 1. Status: APPROVED, REJECTED, or NEEDS-REVISION
202
+ 2. If not approved, list issues with severity (critical/major/minor/suggestion)
203
+ 3. Specific feedback for improvement`;
204
+
205
+ const fullPrompt = `${prompt}\n\n---\n\n${artifact.content}`;
206
+
207
+ try {
208
+ const response = await this.agentRuntime.sendMessage(reviewer, fullPrompt);
209
+ const text = response.text ?? "";
210
+
211
+ const upperText = text.toUpperCase();
212
+ let status: "approved" | "rejected" | "needs-revision" = "needs-revision";
213
+ if (upperText.includes("APPROVED") && !upperText.includes("NOT APPROVED")) {
214
+ status = "approved";
215
+ } else if (upperText.includes("REJECTED")) {
216
+ status = "rejected";
217
+ }
218
+
219
+ return { status, feedback: text, reviewer: reviewer.agentId };
220
+ } catch (err: any) {
221
+ return {
222
+ status: "needs-revision",
223
+ feedback: `Review failed: ${err.message}`,
224
+ reviewer: reviewer.agentId,
225
+ };
226
+ }
227
+ }
228
+
229
+ async executeCode(handle: AgentHandle, code: string, opts?: ExecuteCodeOpts): Promise<ExecutionResult> {
230
+ const language = opts?.language ?? "bash";
231
+ const timeout = opts?.timeout_ms ?? 30000;
232
+ const cwd = opts?.cwd ?? process.cwd();
233
+ const sandbox = opts?.sandbox ?? "strict";
234
+
235
+ let cmd: string;
236
+ let args: string[];
237
+
238
+ switch (language) {
239
+ case "bash":
240
+ case "sh":
241
+ cmd = "/bin/bash";
242
+ args = ["-c", code];
243
+ break;
244
+ case "typescript":
245
+ case "ts":
246
+ cmd = "bun";
247
+ args = ["eval", code];
248
+ break;
249
+ case "python":
250
+ case "py":
251
+ cmd = "python3";
252
+ args = ["-c", code];
253
+ break;
254
+ case "node":
255
+ case "javascript":
256
+ case "js":
257
+ cmd = "node";
258
+ args = ["-e", code];
259
+ break;
260
+ default:
261
+ throw new Error(`Unsupported language: ${language}. Supported: bash, typescript, python, javascript`);
262
+ }
263
+
264
+ return new Promise<ExecutionResult>((resolve) => {
265
+ const startTime = Date.now();
266
+ let stdout = "";
267
+ let stderr = "";
268
+ let killed = false;
269
+
270
+ const child = spawn(cmd, args, {
271
+ cwd: this.validatePath(cwd),
272
+ env: {
273
+ ...this.buildSafeEnv(sandbox),
274
+ ...(opts?.env ?? {}),
275
+ },
276
+ stdio: ["pipe", "pipe", "pipe"],
277
+ });
278
+
279
+ const timer = setTimeout(() => {
280
+ killed = true;
281
+ child.kill("SIGKILL");
282
+ }, timeout);
283
+
284
+ child.stdout.on("data", (data: Buffer) => {
285
+ stdout += data.toString();
286
+ if (stdout.length > 1_048_576) {
287
+ stdout = stdout.slice(0, 1_048_576) + "\n[TRUNCATED]";
288
+ killed = true;
289
+ child.kill("SIGKILL");
290
+ }
291
+ });
292
+
293
+ child.stderr.on("data", (data: Buffer) => {
294
+ stderr += data.toString();
295
+ if (stderr.length > 1_048_576) {
296
+ stderr = stderr.slice(0, 1_048_576) + "\n[TRUNCATED]";
297
+ }
298
+ });
299
+
300
+ child.on("close", (exitCode: number | null) => {
301
+ clearTimeout(timer);
302
+ resolve({
303
+ success: exitCode === 0 && !killed,
304
+ exit_code: exitCode ?? (killed ? 137 : 1),
305
+ stdout,
306
+ stderr:
307
+ killed && !stderr.includes("TRUNCATED")
308
+ ? stderr + "\n[KILLED: timeout or output limit]"
309
+ : stderr,
310
+ duration_ms: Date.now() - startTime,
311
+ });
312
+ });
313
+
314
+ child.on("error", (err: Error) => {
315
+ clearTimeout(timer);
316
+ resolve({
317
+ success: false,
318
+ exit_code: 1,
319
+ stdout,
320
+ stderr: err.message,
321
+ duration_ms: Date.now() - startTime,
322
+ });
323
+ });
324
+ });
325
+ }
326
+
327
+ private buildSafeEnv(sandbox: "strict" | "relaxed"): Record<string, string> {
328
+ const base: Record<string, string> = {
329
+ PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
330
+ HOME: process.env.HOME ?? "/tmp",
331
+ LANG: process.env.LANG ?? "en_US.UTF-8",
332
+ TERM: "dumb",
333
+ };
334
+
335
+ if (sandbox === "relaxed") {
336
+ for (const key of ["NODE_PATH", "BUN_INSTALL", "PYTHONPATH", "SHELL"]) {
337
+ if (process.env[key]) base[key] = process.env[key]!;
338
+ }
339
+ }
340
+
341
+ return base;
342
+ }
343
+
344
+ async invokeSkill(handle: AgentHandle, skillId: string, input: SkillInput): Promise<SkillResult> {
345
+ const skillDir = join(this.projectRoot, "core", "skills", skillId);
346
+ const skillYamlPath = join(skillDir, "skill.yaml");
347
+
348
+ if (!existsSync(skillYamlPath)) {
349
+ throw new UnsupportedError("invokeSkill", `Skill "${skillId}" not found at ${skillYamlPath}`);
350
+ }
351
+
352
+ const skillYamlRaw = readFileSync(skillYamlPath, "utf-8");
353
+ const skillConfig = yaml.load(skillYamlRaw, { schema: yaml.JSON_SCHEMA }) as any;
354
+
355
+ let skillPrompt = skillConfig.description;
356
+ const promptPath = join(skillDir, "prompt.md");
357
+ if (existsSync(promptPath)) {
358
+ skillPrompt = readFileSync(promptPath, "utf-8");
359
+ }
360
+
361
+ const contextParts: string[] = [];
362
+ if (input.args) contextParts.push(`Arguments: ${input.args}`);
363
+ if (input.context) {
364
+ for (const [key, value] of Object.entries(input.context)) {
365
+ contextParts.push(`${key}: ${value}`);
366
+ }
367
+ }
368
+
369
+ const fullPrompt = [skillPrompt, "", "## Input Context", contextParts.join("\n")].join("\n");
370
+
371
+ try {
372
+ const response = await this.agentRuntime.sendMessage(handle, fullPrompt);
373
+ return {
374
+ success: true,
375
+ output: response.text ?? JSON.stringify(response),
376
+ };
377
+ } catch (err: any) {
378
+ return {
379
+ success: false,
380
+ output: "",
381
+ error: err.message ?? String(err),
382
+ };
383
+ }
384
+ }
385
+
386
+ async enforceToolAccess(
387
+ _agentId: string,
388
+ _toolCall: { tool: string; path?: string; command?: string },
389
+ ): Promise<{ allowed: boolean; reason?: string }> {
390
+ return { allowed: true };
391
+ }
392
+ }
package/src/compose.ts ADDED
@@ -0,0 +1,76 @@
1
+ // ── composeAdapter ────────────────────────────────────────────────
2
+ // Combines 4 adapter layers into a single AOSAdapter with explicit
3
+ // method binding. TypeScript enforces the result satisfies AOSAdapter.
4
+
5
+ import type {
6
+ AgentRuntimeAdapter,
7
+ EventBusAdapter,
8
+ UIAdapter,
9
+ WorkflowAdapter,
10
+ AOSAdapter,
11
+ } from "@aos-harness/runtime/types";
12
+
13
+ export function composeAdapter(
14
+ agentRuntime: AgentRuntimeAdapter,
15
+ eventBus: EventBusAdapter,
16
+ ui: UIAdapter,
17
+ workflow: WorkflowAdapter,
18
+ ): AOSAdapter {
19
+ return {
20
+ // AgentRuntimeAdapter (L1)
21
+ spawnAgent: agentRuntime.spawnAgent.bind(agentRuntime),
22
+ sendMessage: agentRuntime.sendMessage.bind(agentRuntime),
23
+ destroyAgent: agentRuntime.destroyAgent.bind(agentRuntime),
24
+ setOrchestratorPrompt: agentRuntime.setOrchestratorPrompt.bind(agentRuntime),
25
+ injectContext: agentRuntime.injectContext.bind(agentRuntime),
26
+ getContextUsage: agentRuntime.getContextUsage.bind(agentRuntime),
27
+ setModel: agentRuntime.setModel.bind(agentRuntime),
28
+ getAuthMode: agentRuntime.getAuthMode.bind(agentRuntime),
29
+ getModelCost: agentRuntime.getModelCost.bind(agentRuntime),
30
+ abort: agentRuntime.abort.bind(agentRuntime),
31
+ spawnSubAgent: agentRuntime.spawnSubAgent.bind(agentRuntime),
32
+ destroySubAgent: agentRuntime.destroySubAgent.bind(agentRuntime),
33
+
34
+ // EventBusAdapter (L2)
35
+ onSessionStart: eventBus.onSessionStart.bind(eventBus),
36
+ onSessionShutdown: eventBus.onSessionShutdown.bind(eventBus),
37
+ onBeforeAgentStart: eventBus.onBeforeAgentStart.bind(eventBus),
38
+ onAgentEnd: eventBus.onAgentEnd.bind(eventBus),
39
+ onToolCall: eventBus.onToolCall.bind(eventBus),
40
+ onToolResult: eventBus.onToolResult.bind(eventBus),
41
+ onMessageEnd: eventBus.onMessageEnd.bind(eventBus),
42
+ onCompaction: eventBus.onCompaction.bind(eventBus),
43
+
44
+ // UIAdapter (L3)
45
+ registerCommand: ui.registerCommand.bind(ui),
46
+ registerTool: ui.registerTool.bind(ui),
47
+ renderAgentResponse: ui.renderAgentResponse.bind(ui),
48
+ renderCustomMessage: ui.renderCustomMessage.bind(ui),
49
+ setWidget: ui.setWidget.bind(ui),
50
+ setFooter: ui.setFooter.bind(ui),
51
+ setStatus: ui.setStatus.bind(ui),
52
+ setTheme: ui.setTheme.bind(ui),
53
+ promptSelect: ui.promptSelect.bind(ui),
54
+ promptConfirm: ui.promptConfirm.bind(ui),
55
+ promptInput: ui.promptInput.bind(ui),
56
+ notify: ui.notify.bind(ui),
57
+ blockInput: ui.blockInput.bind(ui),
58
+ unblockInput: ui.unblockInput.bind(ui),
59
+ steerMessage: ui.steerMessage.bind(ui),
60
+
61
+ // WorkflowAdapter (L4)
62
+ dispatchParallel: workflow.dispatchParallel.bind(workflow),
63
+ isolateWorkspace: workflow.isolateWorkspace.bind(workflow),
64
+ writeFile: workflow.writeFile.bind(workflow),
65
+ readFile: workflow.readFile.bind(workflow),
66
+ openInEditor: workflow.openInEditor.bind(workflow),
67
+ persistState: workflow.persistState.bind(workflow),
68
+ loadState: workflow.loadState.bind(workflow),
69
+ executeCode: workflow.executeCode.bind(workflow),
70
+ invokeSkill: workflow.invokeSkill.bind(workflow),
71
+ createArtifact: workflow.createArtifact.bind(workflow),
72
+ loadArtifact: workflow.loadArtifact.bind(workflow),
73
+ submitForReview: workflow.submitForReview.bind(workflow),
74
+ enforceToolAccess: workflow.enforceToolAccess.bind(workflow),
75
+ };
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type {
2
+ StdoutFormat,
3
+ ParsedEvent,
4
+ ModelInfo,
5
+ HandleState,
6
+ } from "./types";
7
+ export { BaseAgentRuntime } from "./base-agent-runtime";
8
+ export { BaseEventBus } from "./base-event-bus";
9
+ export { TerminalUI } from "./terminal-ui";
10
+ export { BaseWorkflow } from "./base-workflow";
11
+ export { composeAdapter } from "./compose";
@@ -0,0 +1,140 @@
1
+ // ── TerminalUI (L3) ───────────────────────────────────────────────
2
+ // ANSI terminal-native UI for non-Pi adapters.
3
+ // Console-based rendering, readline prompts, command/tool registry.
4
+
5
+ import * as readline from "node:readline";
6
+ import type { UIAdapter } from "@aos-harness/runtime/types";
7
+
8
+ function parseHexColor(hex: string): { r: number; g: number; b: number } {
9
+ const clean = hex.replace(/^#/, "");
10
+ return {
11
+ r: parseInt(clean.substring(0, 2), 16) || 0,
12
+ g: parseInt(clean.substring(2, 4), 16) || 0,
13
+ b: parseInt(clean.substring(4, 6), 16) || 0,
14
+ };
15
+ }
16
+
17
+ export class TerminalUI implements UIAdapter {
18
+ private commands = new Map<string, (args: string) => Promise<void>>();
19
+ private tools = new Map<string, { schema: Record<string, unknown>; handler: (params: Record<string, unknown>) => Promise<unknown> }>();
20
+ private inputBlocked = false;
21
+ private allowedCommands: string[] = [];
22
+ private steeredMessage: string | null = null;
23
+
24
+ registerCommand(name: string, handler: (args: string) => Promise<void>): void {
25
+ this.commands.set(name, handler);
26
+ }
27
+
28
+ async dispatchCommand(name: string, args: string): Promise<boolean> {
29
+ const handler = this.commands.get(name);
30
+ if (!handler) return false;
31
+ await handler(args);
32
+ return true;
33
+ }
34
+
35
+ registerTool(
36
+ name: string,
37
+ schema: Record<string, unknown>,
38
+ handler: (params: Record<string, unknown>) => Promise<unknown>,
39
+ ): void {
40
+ this.tools.set(name, { schema, handler });
41
+ }
42
+
43
+ hasTool(name: string): boolean {
44
+ return this.tools.has(name);
45
+ }
46
+
47
+ async invokeTool(name: string, params: Record<string, unknown>): Promise<unknown> {
48
+ const tool = this.tools.get(name);
49
+ if (!tool) throw new Error(`Unknown tool: ${name}`);
50
+ return await tool.handler(params);
51
+ }
52
+
53
+ renderAgentResponse(agent: string, response: string, color: string): void {
54
+ const { r, g, b } = parseHexColor(color);
55
+ const bgOpen = `\x1b[48;2;${r};${g};${b}m`;
56
+ const fgDark = `\x1b[38;2;30;30;30m`;
57
+ const reset = `\x1b[0m`;
58
+ const dim = `\x1b[2m`;
59
+ console.log(`${bgOpen}${fgDark} ${agent} ${reset}`);
60
+ console.log(`${dim}${response}${reset}`);
61
+ }
62
+
63
+ renderCustomMessage(type: string, content: string, _details: Record<string, unknown>): void {
64
+ console.log(`[${type}] ${content}`);
65
+ }
66
+
67
+ setWidget(_id: string, _renderer: (() => string[]) | undefined): void {}
68
+ setFooter(_renderer: (width: number) => string[]): void {}
69
+ setStatus(_key: string, _text: string): void {}
70
+ setTheme(_name: string): void {}
71
+
72
+ async promptSelect(label: string, options: string[]): Promise<number> {
73
+ console.log(`\n${label}`);
74
+ for (let i = 0; i < options.length; i++) {
75
+ console.log(` ${i + 1}. ${options[i]}`);
76
+ }
77
+ const answer = await this.readLine("Enter number: ");
78
+ const idx = parseInt(answer, 10) - 1;
79
+ return idx >= 0 && idx < options.length ? idx : 0;
80
+ }
81
+
82
+ async promptConfirm(title: string, message: string): Promise<boolean> {
83
+ console.log(`\n${title}`);
84
+ console.log(message);
85
+ const answer = await this.readLine("Confirm? (y/n): ");
86
+ return answer.toLowerCase().startsWith("y");
87
+ }
88
+
89
+ async promptInput(label: string): Promise<string> {
90
+ return this.readLine(`${label}: `);
91
+ }
92
+
93
+ private readLine(prompt: string): Promise<string> {
94
+ const rl = readline.createInterface({
95
+ input: process.stdin,
96
+ output: process.stdout,
97
+ });
98
+ return new Promise((resolve) => {
99
+ rl.question(prompt, (answer) => {
100
+ rl.close();
101
+ resolve(answer.trim());
102
+ });
103
+ });
104
+ }
105
+
106
+ notify(message: string, level: "info" | "warning" | "error"): void {
107
+ const prefix = level === "error" ? "[ERROR]" : level === "warning" ? "[WARN]" : "[INFO]";
108
+ const colorCode = level === "error" ? "\x1b[31m" : level === "warning" ? "\x1b[33m" : "\x1b[36m";
109
+ const reset = "\x1b[0m";
110
+ console.log(`${colorCode}${prefix}${reset} ${message}`);
111
+ }
112
+
113
+ blockInput(allowedCommands: string[]): void {
114
+ this.inputBlocked = true;
115
+ this.allowedCommands = allowedCommands;
116
+ }
117
+
118
+ unblockInput(): void {
119
+ this.inputBlocked = false;
120
+ this.allowedCommands = [];
121
+ }
122
+
123
+ isInputBlocked(): boolean {
124
+ return this.inputBlocked;
125
+ }
126
+
127
+ getAllowedCommands(): string[] {
128
+ return this.allowedCommands;
129
+ }
130
+
131
+ steerMessage(message: string): void {
132
+ this.steeredMessage = message;
133
+ }
134
+
135
+ consumeSteeredMessage(): string | null {
136
+ const msg = this.steeredMessage;
137
+ this.steeredMessage = null;
138
+ return msg;
139
+ }
140
+ }
package/src/types.ts ADDED
@@ -0,0 +1,43 @@
1
+ // ── Shared Adapter Types ──────────────────────────────────────────
2
+
3
+ import type { AgentConfig, ModelTier, ThinkingMode } from "@aos-harness/runtime/types";
4
+
5
+ // ── Stdout format declaration ────────────────────────────────────
6
+
7
+ export type StdoutFormat = "ndjson" | "sse" | "chunked-json";
8
+
9
+ // ── Parsed event normalization ───────────────────────────────────
10
+
11
+ export type ParsedEvent =
12
+ | { type: "text_delta"; text: string }
13
+ | {
14
+ type: "message_end";
15
+ text: string;
16
+ tokensIn: number;
17
+ tokensOut: number;
18
+ cost: number;
19
+ contextTokens: number;
20
+ model: string;
21
+ }
22
+ | { type: "tool_call"; name: string; input: unknown }
23
+ | { type: "tool_result"; name: string; input: unknown; result: unknown }
24
+ | { type: "ignored" };
25
+
26
+ // ── Model info from CLI discovery ────────────────────────────────
27
+
28
+ export interface ModelInfo {
29
+ id: string;
30
+ name: string;
31
+ contextWindow: number;
32
+ provider: string;
33
+ }
34
+
35
+ // ── Per-handle state tracked by BaseAgentRuntime ─────────────────
36
+
37
+ export interface HandleState {
38
+ config: AgentConfig;
39
+ sessionFile: string;
40
+ contextFiles: string[];
41
+ modelConfig: { tier: ModelTier; thinking: ThinkingMode };
42
+ lastContextTokens: number;
43
+ }