phoebe-agent 0.0.0 → 0.1.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/README.md +77 -8
- package/bootstrap/bin.mjs +42 -0
- package/bootstrap/boot.ts +431 -0
- package/bootstrap/cli.ts +29 -0
- package/bootstrap/crash-loop.ts +391 -0
- package/bootstrap/define-config.ts +20 -0
- package/bootstrap/engine-source.ts +83 -0
- package/bootstrap/github-engine.ts +169 -0
- package/bootstrap/index.mjs +9 -0
- package/bootstrap/index.ts +24 -0
- package/bootstrap/materialize.mjs +53 -0
- package/bootstrap/reconcile.ts +314 -0
- package/bootstrap/spawn-engine.mjs +78 -0
- package/package.json +26 -24
- package/prompts/checks-prompt.md +21 -6
- package/prompts/conflict-prompt.md +12 -1
- package/prompts/research-prompt.md +61 -0
- package/src/agent-env.ts +32 -0
- package/src/branded.ts +19 -0
- package/src/cli.ts +187 -0
- package/src/config-schema.ts +278 -0
- package/src/drain.ts +74 -0
- package/src/execution-gate.ts +27 -0
- package/src/git-model.ts +145 -0
- package/src/init.ts +277 -0
- package/src/load-config.ts +168 -0
- package/src/main.ts +1636 -0
- package/src/orchestrator.ts +953 -0
- package/src/prompt.ts +98 -0
- package/src/providers/providers.ts +222 -0
- package/src/providers/run-agent.ts +90 -0
- package/src/providers/types.ts +26 -0
- package/src/resolved-config.ts +55 -0
- package/templates/.env.example +17 -5
- package/templates/container/Dockerfile +128 -19
- package/templates/container/compose.local.yml +37 -0
- package/templates/container/compose.yml +43 -13
- package/templates/phoebe.config.ts +30 -6
- package/dist/phoebe.config.d.ts +0 -2
- package/dist/phoebe.config.js +0 -43
- package/dist/src/agent-env.d.ts +0 -6
- package/dist/src/agent-env.js +0 -24
- package/dist/src/cli.d.ts +0 -25
- package/dist/src/cli.js +0 -161
- package/dist/src/config-schema.d.ts +0 -163
- package/dist/src/config-schema.js +0 -140
- package/dist/src/execution-gate.d.ts +0 -9
- package/dist/src/execution-gate.js +0 -17
- package/dist/src/git-model.d.ts +0 -30
- package/dist/src/git-model.js +0 -71
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.js +0 -12
- package/dist/src/init.d.ts +0 -82
- package/dist/src/init.js +0 -207
- package/dist/src/load-config.d.ts +0 -88
- package/dist/src/load-config.js +0 -153
- package/dist/src/main.d.ts +0 -7
- package/dist/src/main.js +0 -1180
- package/dist/src/orchestrator.d.ts +0 -275
- package/dist/src/orchestrator.js +0 -579
- package/dist/src/prompt.d.ts +0 -13
- package/dist/src/prompt.js +0 -55
- package/dist/src/providers/providers.d.ts +0 -3
- package/dist/src/providers/providers.js +0 -210
- package/dist/src/providers/run-agent.d.ts +0 -34
- package/dist/src/providers/run-agent.js +0 -57
- package/dist/src/providers/types.d.ts +0 -27
- package/dist/src/providers/types.js +0 -6
- package/dist/src/resolved-config.d.ts +0 -8
- package/dist/src/resolved-config.js +0 -49
- package/dist/src/supervisor-decision.d.ts +0 -70
- package/dist/src/supervisor-decision.js +0 -94
- package/templates/container/compose.daemon.yml +0 -16
- package/templates/container/supervisor.sh +0 -50
- /package/prompts/{prompt.md → issues-prompt.md} +0 -0
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Prompt template rendering: {{KEY}} argument substitution plus !`command`
|
|
2
|
+
// shell expansion, executed in the work unit's worktree. The marker trick is
|
|
3
|
+
// ported from Sandcastle's PromptPreprocessor: shell blocks present in the raw
|
|
4
|
+
// template are marked *before* argument substitution, so `!`...`` patterns
|
|
5
|
+
// arriving via substituted values are treated as data, never executed.
|
|
6
|
+
//
|
|
7
|
+
// Prompt file paths (`config.promptFiles.*`) resolve against the runtime root
|
|
8
|
+
// (process cwd) — the consumer checkout on the host, `/etc/phoebe` in the
|
|
9
|
+
// container where compose mounts `phoebe.config.ts` and `prompts/`. They do
|
|
10
|
+
// not walk the installed package; `phoebe init` copies shipped prompts into
|
|
11
|
+
// the runtime root for that reason.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { isAbsolute, resolve } from "node:path";
|
|
15
|
+
import type { PhoebeConfig } from "./config-schema.ts";
|
|
16
|
+
|
|
17
|
+
export type PromptArgs = Record<string, string>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a `promptFiles.*` path against the runtime root. Absolute paths are
|
|
21
|
+
* used as-is; relative paths join to `runtimeRoot`. Throws when the file is
|
|
22
|
+
* missing — never falls back into the installed package tree.
|
|
23
|
+
*/
|
|
24
|
+
export function resolvePromptFile(promptPath: string, runtimeRoot: string): string {
|
|
25
|
+
const absolute = isAbsolute(promptPath) ? promptPath : resolve(runtimeRoot, promptPath);
|
|
26
|
+
if (!existsSync(absolute)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Could not find prompt file ${promptPath} (resolved to ${absolute} from runtime root ${runtimeRoot})`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return absolute;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read a prompt template from a path relative to (or absolute under) the runtime root. */
|
|
35
|
+
export function loadPromptTemplate(promptPath: string, runtimeRoot: string): string {
|
|
36
|
+
return readFileSync(resolvePromptFile(promptPath, runtimeRoot), "utf8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The standard placeholder set every default prompt template can reference —
|
|
41
|
+
* derived once per run from the resolved config so callers can retarget the
|
|
42
|
+
* toolchain by editing `phoebe.config.ts` alone. Per-callsite args
|
|
43
|
+
* (`ISSUE_NUMBER`, `PR_NUMBER`, …) are merged on top by `runAgentInWorktree`.
|
|
44
|
+
*/
|
|
45
|
+
export function buildDefaultPromptArgs(config: PhoebeConfig): PromptArgs {
|
|
46
|
+
return {
|
|
47
|
+
INSTALL_COMMAND: config.installCommand,
|
|
48
|
+
CHECK_COMMAND: config.checkCommand,
|
|
49
|
+
TEST_COMMAND: config.testCommand,
|
|
50
|
+
READY_COMMAND: config.readyCommand,
|
|
51
|
+
DEFAULT_BRANCH: config.defaultBranch,
|
|
52
|
+
BRANCH_PREFIX: config.branchPrefix,
|
|
53
|
+
READY_LABEL: config.readyLabel,
|
|
54
|
+
RESEARCH_LABEL: config.researchLabel,
|
|
55
|
+
PROCESSING_LABEL: config.processingLabel,
|
|
56
|
+
REVIEWS_SUCCESS_HEADING: config.reviewsSuccessHeading,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Marker inserted between `!` and the opening backtick for shell blocks that
|
|
62
|
+
* appear in the raw template. Only marked blocks are executed.
|
|
63
|
+
*/
|
|
64
|
+
const SHELL_BLOCK_MARKER = "\x01";
|
|
65
|
+
|
|
66
|
+
const SHELL_BLOCK_PATTERN = /!`([^`]+)`/g;
|
|
67
|
+
const MARKED_SHELL_BLOCK_PATTERN = new RegExp(`!${SHELL_BLOCK_MARKER}\`([^\`]+)\``, "g");
|
|
68
|
+
const PLACEHOLDER_PATTERN = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
69
|
+
|
|
70
|
+
export function substitutePromptArgs(template: string, args: PromptArgs): string {
|
|
71
|
+
const marked = template.replace(SHELL_BLOCK_PATTERN, (_m, cmd: string) => {
|
|
72
|
+
return `!${SHELL_BLOCK_MARKER}\`${cmd}\``;
|
|
73
|
+
});
|
|
74
|
+
return marked.replace(PLACEHOLDER_PATTERN, (match, key: string) => {
|
|
75
|
+
const value = args[key];
|
|
76
|
+
if (value === undefined) {
|
|
77
|
+
throw new Error(`Prompt placeholder {{${key}}} has no value.`);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Execute marked shell blocks and splice their trimmed stdout into the prompt. */
|
|
84
|
+
export function expandShellBlocks(prompt: string, execShell: (command: string) => string): string {
|
|
85
|
+
return prompt
|
|
86
|
+
.replace(MARKED_SHELL_BLOCK_PATTERN, (_m, command: string) => {
|
|
87
|
+
return execShell(command).trimEnd();
|
|
88
|
+
})
|
|
89
|
+
.replaceAll(SHELL_BLOCK_MARKER, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function renderPrompt(
|
|
93
|
+
template: string,
|
|
94
|
+
args: PromptArgs,
|
|
95
|
+
execShell: (command: string) => string,
|
|
96
|
+
): string {
|
|
97
|
+
return expandShellBlocks(substitutePromptArgs(template, args), execShell);
|
|
98
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The three agent CLIs Phoebe can drive. Invocation flags and stream-JSON
|
|
2
|
+
// schemas were authored from Sandcastle 0.8.0's AgentProvider.ts (the
|
|
3
|
+
// `@ai-hero/sandcastle` package sources) and carry its field-level
|
|
4
|
+
// knowledge of each CLI's output format.
|
|
5
|
+
|
|
6
|
+
import type { AgentCommand, AgentEvent, Provider } from "./types.ts";
|
|
7
|
+
import type { ProviderName } from "../config-schema.ts";
|
|
8
|
+
|
|
9
|
+
/** Maps allowlisted tool names to the input field carrying the display arg. */
|
|
10
|
+
const TOOL_ARG_FIELDS: Record<string, string> = {
|
|
11
|
+
Bash: "command",
|
|
12
|
+
WebSearch: "query",
|
|
13
|
+
WebFetch: "url",
|
|
14
|
+
Agent: "description",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Claude Code and Cursor share the Claude stream-json line schema:
|
|
19
|
+
* `assistant` messages with text/tool_use content blocks, plus a terminal
|
|
20
|
+
* `result` event.
|
|
21
|
+
*/
|
|
22
|
+
const parseClaudeStreamLine = (line: string): AgentEvent[] => {
|
|
23
|
+
if (!line.startsWith("{")) return [];
|
|
24
|
+
try {
|
|
25
|
+
const obj = JSON.parse(line) as Record<string, unknown>;
|
|
26
|
+
const message = obj["message"] as { content?: unknown } | undefined;
|
|
27
|
+
if (obj["type"] === "assistant" && Array.isArray(message?.content)) {
|
|
28
|
+
const events: AgentEvent[] = [];
|
|
29
|
+
const texts: string[] = [];
|
|
30
|
+
for (const block of message.content as Array<{
|
|
31
|
+
type: string;
|
|
32
|
+
text?: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
input?: Record<string, unknown>;
|
|
35
|
+
}>) {
|
|
36
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
37
|
+
texts.push(block.text);
|
|
38
|
+
} else if (
|
|
39
|
+
block.type === "tool_use" &&
|
|
40
|
+
typeof block.name === "string" &&
|
|
41
|
+
block.input !== undefined
|
|
42
|
+
) {
|
|
43
|
+
const argField = TOOL_ARG_FIELDS[block.name];
|
|
44
|
+
if (argField === undefined) continue;
|
|
45
|
+
const argValue = block.input[argField];
|
|
46
|
+
if (typeof argValue !== "string") continue;
|
|
47
|
+
if (texts.length > 0) {
|
|
48
|
+
events.push({ type: "text", text: texts.join("") });
|
|
49
|
+
texts.length = 0;
|
|
50
|
+
}
|
|
51
|
+
events.push({ type: "tool_call", name: block.name, args: argValue });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (texts.length > 0) {
|
|
55
|
+
events.push({ type: "text", text: texts.join("") });
|
|
56
|
+
}
|
|
57
|
+
return events;
|
|
58
|
+
}
|
|
59
|
+
if (obj["type"] === "result" && typeof obj["result"] === "string") {
|
|
60
|
+
return [{ type: "result", result: obj["result"] }];
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Not valid JSON — skip.
|
|
64
|
+
}
|
|
65
|
+
return [];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Cursor additionally emits top-level `tool_call` events with per-tool shapes. */
|
|
69
|
+
const parseCursorStreamLine = (line: string): AgentEvent[] => {
|
|
70
|
+
if (!line.startsWith("{")) return [];
|
|
71
|
+
let obj: Record<string, unknown>;
|
|
72
|
+
try {
|
|
73
|
+
obj = JSON.parse(line) as Record<string, unknown>;
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
if (obj["type"] !== "tool_call" || obj["subtype"] !== "started") {
|
|
78
|
+
return parseClaudeStreamLine(line);
|
|
79
|
+
}
|
|
80
|
+
const toolCall = obj["tool_call"];
|
|
81
|
+
if (!toolCall || typeof toolCall !== "object") return [];
|
|
82
|
+
const tc = toolCall as Record<string, unknown>;
|
|
83
|
+
|
|
84
|
+
const readToolCall = tc["readToolCall"] as { args?: { path?: unknown } } | undefined;
|
|
85
|
+
if (readToolCall?.args && typeof readToolCall.args.path === "string") {
|
|
86
|
+
return [{ type: "tool_call", name: "Read", args: readToolCall.args.path }];
|
|
87
|
+
}
|
|
88
|
+
const writeToolCall = tc["writeToolCall"] as { args?: { path?: unknown } } | undefined;
|
|
89
|
+
if (writeToolCall?.args && typeof writeToolCall.args.path === "string") {
|
|
90
|
+
return [{ type: "tool_call", name: "Write", args: writeToolCall.args.path }];
|
|
91
|
+
}
|
|
92
|
+
const fn = tc["function"] as { name?: unknown; arguments?: unknown } | undefined;
|
|
93
|
+
if (fn && typeof fn.name === "string") {
|
|
94
|
+
const rawArgs = typeof fn.arguments === "string" ? fn.arguments : "";
|
|
95
|
+
if (rawArgs) {
|
|
96
|
+
try {
|
|
97
|
+
const parsedArgs = JSON.parse(rawArgs) as Record<string, unknown>;
|
|
98
|
+
if (typeof parsedArgs["command"] === "string") {
|
|
99
|
+
return [{ type: "tool_call", name: "Bash", args: parsedArgs["command"] }];
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Fall through to the raw arguments string.
|
|
103
|
+
}
|
|
104
|
+
return [{ type: "tool_call", name: fn.name, args: rawArgs }];
|
|
105
|
+
}
|
|
106
|
+
return [{ type: "tool_call", name: fn.name, args: "" }];
|
|
107
|
+
}
|
|
108
|
+
return [];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const extractErrorMessage = (obj: Record<string, unknown>): string | undefined => {
|
|
112
|
+
const err = obj["error"];
|
|
113
|
+
if (typeof err === "string") return err;
|
|
114
|
+
if (typeof err === "object" && err !== null) {
|
|
115
|
+
const e = err as { message?: unknown; data?: { message?: unknown } };
|
|
116
|
+
if (typeof e.message === "string") return e.message;
|
|
117
|
+
if (typeof e.data?.message === "string") return e.data.message;
|
|
118
|
+
}
|
|
119
|
+
if (typeof obj["message"] === "string") return obj["message"];
|
|
120
|
+
return undefined;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Codex `exec --json` emits item/turn events rather than message blocks. */
|
|
124
|
+
const parseCodexStreamLine = (line: string): AgentEvent[] => {
|
|
125
|
+
if (!line.startsWith("{")) return [];
|
|
126
|
+
try {
|
|
127
|
+
const obj = JSON.parse(line) as Record<string, unknown>;
|
|
128
|
+
const item = obj["item"] as { type?: string; text?: unknown; command?: unknown } | undefined;
|
|
129
|
+
if (
|
|
130
|
+
obj["type"] === "item.completed" &&
|
|
131
|
+
item?.type === "agent_message" &&
|
|
132
|
+
typeof item.text === "string"
|
|
133
|
+
) {
|
|
134
|
+
return [
|
|
135
|
+
{ type: "text", text: item.text },
|
|
136
|
+
{ type: "result", result: item.text },
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
obj["type"] === "item.started" &&
|
|
141
|
+
item?.type === "command_execution" &&
|
|
142
|
+
typeof item.command === "string"
|
|
143
|
+
) {
|
|
144
|
+
return [{ type: "tool_call", name: "Bash", args: item.command }];
|
|
145
|
+
}
|
|
146
|
+
// Codex reports auth/rate-limit/API errors on stdout, not stderr.
|
|
147
|
+
if (obj["type"] === "error") {
|
|
148
|
+
const msg = extractErrorMessage(obj);
|
|
149
|
+
return msg ? [{ type: "result", result: msg }] : [];
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Not valid JSON — skip.
|
|
153
|
+
}
|
|
154
|
+
return [];
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The Cursor CLI takes the prompt as a positional argv argument (stdin is not
|
|
159
|
+
* documented for prompt delivery). Linux caps a single argument at ~128 KiB;
|
|
160
|
+
* stay under it so users get a clear error instead of spawn E2BIG.
|
|
161
|
+
*/
|
|
162
|
+
const CURSOR_PROMPT_MAX_BYTES = 120 * 1024;
|
|
163
|
+
|
|
164
|
+
const cursor: Provider = {
|
|
165
|
+
name: "cursor",
|
|
166
|
+
buildCommand({ prompt, model }): AgentCommand {
|
|
167
|
+
const bytes = Buffer.byteLength(prompt, "utf8");
|
|
168
|
+
if (bytes > CURSOR_PROMPT_MAX_BYTES) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Cursor prompt is ${bytes} bytes (max ${CURSOR_PROMPT_MAX_BYTES}). The Cursor CLI only accepts the prompt as an argv argument; shorten the prompt or use another provider.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
argv: [
|
|
175
|
+
"agent",
|
|
176
|
+
"--print",
|
|
177
|
+
"--output-format",
|
|
178
|
+
"stream-json",
|
|
179
|
+
"--model",
|
|
180
|
+
model,
|
|
181
|
+
"--force",
|
|
182
|
+
prompt,
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
parseStreamLine: parseCursorStreamLine,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const claude: Provider = {
|
|
190
|
+
name: "claude",
|
|
191
|
+
buildCommand({ prompt, model }): AgentCommand {
|
|
192
|
+
return {
|
|
193
|
+
argv: [
|
|
194
|
+
"claude",
|
|
195
|
+
"--print",
|
|
196
|
+
"--verbose",
|
|
197
|
+
"--dangerously-skip-permissions",
|
|
198
|
+
"--output-format",
|
|
199
|
+
"stream-json",
|
|
200
|
+
"--model",
|
|
201
|
+
model,
|
|
202
|
+
"-p",
|
|
203
|
+
"-",
|
|
204
|
+
],
|
|
205
|
+
stdin: prompt,
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
parseStreamLine: parseClaudeStreamLine,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const codex: Provider = {
|
|
212
|
+
name: "codex",
|
|
213
|
+
buildCommand({ prompt, model }): AgentCommand {
|
|
214
|
+
return {
|
|
215
|
+
argv: ["codex", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "-m", model],
|
|
216
|
+
stdin: prompt,
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
parseStreamLine: parseCodexStreamLine,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export const PROVIDERS: Record<ProviderName, Provider> = { cursor, claude, codex };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Spawn an agent CLI for one work unit and stream its output. The child's
|
|
2
|
+
// argv/stdin come from the provider; the env is the caller-built allowlist
|
|
3
|
+
// (see ../agent-env.ts) — never the orchestrator's full process.env.
|
|
4
|
+
|
|
5
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
6
|
+
import type { Provider } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export type AgentRunResult = {
|
|
9
|
+
exitCode: number;
|
|
10
|
+
/** Last `result` event the provider emitted, if any. */
|
|
11
|
+
resultText: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Minimal child-process surface so tests can inject a fake spawn. */
|
|
15
|
+
export type AgentChild = {
|
|
16
|
+
stdout: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown };
|
|
17
|
+
stderr: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown };
|
|
18
|
+
stdin: { write(data: string): unknown; end(): unknown };
|
|
19
|
+
on(event: "error", listener: (err: Error) => void): unknown;
|
|
20
|
+
on(event: "close", listener: (code: number | null) => void): unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SpawnAgent = (
|
|
24
|
+
file: string,
|
|
25
|
+
args: readonly string[],
|
|
26
|
+
opts: { cwd: string; env: Record<string, string> },
|
|
27
|
+
) => AgentChild;
|
|
28
|
+
|
|
29
|
+
const defaultSpawn: SpawnAgent = (file, args, opts) =>
|
|
30
|
+
nodeSpawn(file, args, { ...opts, stdio: ["pipe", "pipe", "pipe"] });
|
|
31
|
+
|
|
32
|
+
export async function runAgent(opts: {
|
|
33
|
+
provider: Provider;
|
|
34
|
+
model: string;
|
|
35
|
+
prompt: string;
|
|
36
|
+
cwd: string;
|
|
37
|
+
env: Record<string, string>;
|
|
38
|
+
spawn?: SpawnAgent;
|
|
39
|
+
log?: (line: string) => void;
|
|
40
|
+
}): Promise<AgentRunResult> {
|
|
41
|
+
const { provider, model, prompt, cwd, env } = opts;
|
|
42
|
+
const spawn = opts.spawn ?? defaultSpawn;
|
|
43
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
44
|
+
|
|
45
|
+
const command = provider.buildCommand({ prompt, model });
|
|
46
|
+
const [file, ...args] = command.argv;
|
|
47
|
+
if (!file) {
|
|
48
|
+
throw new Error(`Provider "${provider.name}" built an empty command.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return new Promise<AgentRunResult>((resolve, reject) => {
|
|
52
|
+
const child = spawn(file, args, { cwd, env });
|
|
53
|
+
let resultText = "";
|
|
54
|
+
let stdoutBuffer = "";
|
|
55
|
+
|
|
56
|
+
const handleLine = (line: string): void => {
|
|
57
|
+
for (const event of provider.parseStreamLine(line)) {
|
|
58
|
+
if (event.type === "text") {
|
|
59
|
+
const text = event.text.trim();
|
|
60
|
+
if (text) log(`[${provider.name}] ${text}`);
|
|
61
|
+
} else if (event.type === "tool_call") {
|
|
62
|
+
log(`[${provider.name}] ${event.name}: ${event.args}`);
|
|
63
|
+
} else {
|
|
64
|
+
resultText = event.result;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
child.stdout.on("data", (chunk) => {
|
|
70
|
+
stdoutBuffer += chunk.toString();
|
|
71
|
+
const lines = stdoutBuffer.split("\n");
|
|
72
|
+
stdoutBuffer = lines.pop() ?? "";
|
|
73
|
+
for (const line of lines) handleLine(line);
|
|
74
|
+
});
|
|
75
|
+
child.stderr.on("data", (chunk) => {
|
|
76
|
+
const text = chunk.toString().trim();
|
|
77
|
+
if (text) log(`[${provider.name}:stderr] ${text}`);
|
|
78
|
+
});
|
|
79
|
+
child.on("error", reject);
|
|
80
|
+
child.on("close", (code) => {
|
|
81
|
+
if (stdoutBuffer) handleLine(stdoutBuffer);
|
|
82
|
+
resolve({ exitCode: code ?? 1, resultText });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (command.stdin !== undefined) {
|
|
86
|
+
child.stdin.write(command.stdin);
|
|
87
|
+
}
|
|
88
|
+
child.stdin.end();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Agent provider contract — how Phoebe invokes a coding-agent CLI and reads
|
|
2
|
+
// its output stream. Ported from Sandcastle's AgentProvider design (Matt
|
|
3
|
+
// Pocock, https://github.com/mattpocock/sandcastle — the design ancestor of
|
|
4
|
+
// this engine), trimmed to what Phoebe uses: one print-mode run per work unit,
|
|
5
|
+
// no session capture, no interactive mode.
|
|
6
|
+
|
|
7
|
+
import type { ProviderName } from "../config-schema.ts";
|
|
8
|
+
|
|
9
|
+
/** A ready-to-spawn CLI invocation. `argv[0]` is the binary; no shell involved. */
|
|
10
|
+
export type AgentCommand = {
|
|
11
|
+
readonly argv: readonly string[];
|
|
12
|
+
/** When set, piped to the child's stdin (large prompts don't fit argv). */
|
|
13
|
+
readonly stdin?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Events parsed from one line of the agent's JSONL output stream. */
|
|
17
|
+
export type AgentEvent =
|
|
18
|
+
| { type: "text"; text: string }
|
|
19
|
+
| { type: "result"; result: string }
|
|
20
|
+
| { type: "tool_call"; name: string; args: string };
|
|
21
|
+
|
|
22
|
+
export type Provider = {
|
|
23
|
+
readonly name: ProviderName;
|
|
24
|
+
buildCommand(opts: { prompt: string; model: string }): AgentCommand;
|
|
25
|
+
parseStreamLine(line: string): AgentEvent[];
|
|
26
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Runtime handle to the resolved, defaults-filled config the engine reads.
|
|
2
|
+
//
|
|
3
|
+
// The engine (everything else under src/) imports `config` from here and stays
|
|
4
|
+
// repo-agnostic; the CLI (src/cli.ts) loads the consumer's `phoebe.config.ts`,
|
|
5
|
+
// applies the `PHOEBE_*` env overlay, resolves defaults, and installs the
|
|
6
|
+
// result via `setResolvedConfig` before importing the engine entry point.
|
|
7
|
+
// Tests install a sample config via the setup file wired in `vite.config.ts`.
|
|
8
|
+
//
|
|
9
|
+
// A `Proxy` gates every field read: reading before install throws with a
|
|
10
|
+
// pointer at the CLI/setup path, so an accidentally repo-coupled engine import
|
|
11
|
+
// fails loudly at the callsite instead of silently reading `undefined`.
|
|
12
|
+
|
|
13
|
+
import type { PhoebeConfig } from "./config-schema.ts";
|
|
14
|
+
|
|
15
|
+
let resolved: PhoebeConfig | null = null;
|
|
16
|
+
|
|
17
|
+
/** Install the resolved config. Idempotent — later calls replace the prior value. */
|
|
18
|
+
export function setResolvedConfig(next: PhoebeConfig): void {
|
|
19
|
+
resolved = next;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** For test isolation: clear the installed config back to the "not installed" state. */
|
|
23
|
+
export function clearResolvedConfig(): void {
|
|
24
|
+
resolved = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether a resolved config has been installed. */
|
|
28
|
+
export function hasResolvedConfig(): boolean {
|
|
29
|
+
return resolved !== null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const config: PhoebeConfig = new Proxy({} as PhoebeConfig, {
|
|
33
|
+
get(_target, prop) {
|
|
34
|
+
if (resolved === null) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Attempted to read config.${String(prop)} before the resolved config was installed. ` +
|
|
37
|
+
`The Phoebe CLI (src/cli.ts) installs it after loading phoebe.config.ts; ` +
|
|
38
|
+
`tests install a sample via src/test-setup.ts (wired from vite.config.ts).`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return resolved[prop as keyof PhoebeConfig];
|
|
42
|
+
},
|
|
43
|
+
ownKeys() {
|
|
44
|
+
if (resolved === null) return [];
|
|
45
|
+
return Reflect.ownKeys(resolved);
|
|
46
|
+
},
|
|
47
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
48
|
+
if (resolved === null) return undefined;
|
|
49
|
+
return Reflect.getOwnPropertyDescriptor(resolved, prop);
|
|
50
|
+
},
|
|
51
|
+
has(_target, prop) {
|
|
52
|
+
if (resolved === null) return false;
|
|
53
|
+
return prop in resolved;
|
|
54
|
+
},
|
|
55
|
+
});
|
package/templates/.env.example
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# Phoebe environment — scaffolded by `phoebe init`. Copy to `.env` and fill in.
|
|
2
2
|
#
|
|
3
|
-
# GH_TOKEN is required; every `gh` call in the engine reads it
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# GH_TOKEN is required; every `gh` call in the engine reads it, and `phoebe
|
|
4
|
+
# boot` uses it to check the engine out from GitHub. The provider key is
|
|
5
|
+
# required only for whichever agent you actually run — set the one that matches
|
|
6
|
+
# your `defaultProvider` (or `PHOEBE_AGENT` at runtime).
|
|
7
|
+
#
|
|
8
|
+
# Which engine version runs is NOT set here: it is `engine.ref` in
|
|
9
|
+
# phoebe.config.ts, which `boot` re-reads while the container runs.
|
|
6
10
|
|
|
7
11
|
# --- Required ---
|
|
8
12
|
GH_TOKEN=
|
|
@@ -12,10 +16,18 @@ ANTHROPIC_API_KEY=
|
|
|
12
16
|
CURSOR_API_KEY=
|
|
13
17
|
OPENAI_KEY=
|
|
14
18
|
|
|
19
|
+
# --- One host, multiple repos (set this if so) ---
|
|
20
|
+
# Namespaces this instance's containers and named volumes. REQUIRED to be unique
|
|
21
|
+
# per repo when you run Phoebe for more than one repo on the same host —
|
|
22
|
+
# otherwise every instance shares one /data/repo clone and works the wrong repo.
|
|
23
|
+
# COMPOSE_PROJECT_NAME=phoebe-your-org-your-repo
|
|
24
|
+
|
|
15
25
|
# --- Runtime toggles (optional) ---
|
|
16
26
|
# PHOEBE_AGENT=claude
|
|
17
27
|
# PHOEBE_MODEL=claude-sonnet-4-6
|
|
18
28
|
# PHOEBE_POLL_INTERVAL_MS=300000
|
|
29
|
+
# How often `boot` checks the config and the tracked engine ref (default 60000).
|
|
30
|
+
# PHOEBE_RECONCILE_INTERVAL_MS=60000
|
|
19
31
|
|
|
20
|
-
# ---
|
|
21
|
-
|
|
32
|
+
# --- Local engine checkout (compose.local.yml only, development) ---
|
|
33
|
+
# PHOEBE_ENGINE_MOUNT=/path/to/phoebe
|