@gr8ful/spf 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 +321 -0
- package/assets/defaults/spf.config.yaml +141 -0
- package/assets/prompts/builder/system.md +13 -0
- package/assets/prompts/builder/user.md +34 -0
- package/assets/prompts/documenter/system.md +17 -0
- package/assets/prompts/documenter/user.md +48 -0
- package/assets/prompts/planner/system.md +21 -0
- package/assets/prompts/planner/user.md +45 -0
- package/assets/prompts/reviewer/system.md +16 -0
- package/assets/prompts/reviewer/user.md +44 -0
- package/assets/prompts/scout/system.md +20 -0
- package/assets/prompts/scout/user.md +34 -0
- package/assets/skill/SKILL.md +80 -0
- package/assets/skill/cookbooks/authoring_chains.md +193 -0
- package/assets/skill/cookbooks/how_to_prompt_for_the_eng.md +109 -0
- package/assets/skill/cookbooks/roster.md +197 -0
- package/assets/skill/cookbooks/run_adw.md +92 -0
- package/assets/skill/cookbooks/spf_overview.md +111 -0
- package/assets/skill/references/config.md +188 -0
- package/assets/skill/references/handoff.md +162 -0
- package/assets/skill/references/observability.md +184 -0
- package/dist/chains/adw_build.d.ts +12 -0
- package/dist/chains/adw_build.js +27 -0
- package/dist/chains/adw_build_review.d.ts +21 -0
- package/dist/chains/adw_build_review.js +55 -0
- package/dist/chains/adw_build_test.d.ts +21 -0
- package/dist/chains/adw_build_test.js +67 -0
- package/dist/chains/adw_document.d.ts +23 -0
- package/dist/chains/adw_document.js +59 -0
- package/dist/chains/adw_plan.d.ts +12 -0
- package/dist/chains/adw_plan.js +27 -0
- package/dist/chains/adw_plan_build.d.ts +12 -0
- package/dist/chains/adw_plan_build.js +30 -0
- package/dist/chains/adw_plan_build_test.d.ts +16 -0
- package/dist/chains/adw_plan_build_test.js +65 -0
- package/dist/chains/adw_plan_build_test_quality.d.ts +18 -0
- package/dist/chains/adw_plan_build_test_quality.js +66 -0
- package/dist/chains/adw_prompt.d.ts +12 -0
- package/dist/chains/adw_prompt.js +25 -0
- package/dist/chains/adw_quality.d.ts +12 -0
- package/dist/chains/adw_quality.js +32 -0
- package/dist/chains/adw_scout.d.ts +12 -0
- package/dist/chains/adw_scout.js +27 -0
- package/dist/chains/adw_simple_sdlc.d.ts +43 -0
- package/dist/chains/adw_simple_sdlc.js +147 -0
- package/dist/chains/context.d.ts +19 -0
- package/dist/chains/context.js +1 -0
- package/dist/chains/index.d.ts +19 -0
- package/dist/chains/index.js +116 -0
- package/dist/cli/bin.d.ts +15 -0
- package/dist/cli/bin.js +24 -0
- package/dist/cli/commands/abort.d.ts +1 -0
- package/dist/cli/commands/abort.js +42 -0
- package/dist/cli/commands/doctor.d.ts +1 -0
- package/dist/cli/commands/doctor.js +165 -0
- package/dist/cli/commands/eject.d.ts +1 -0
- package/dist/cli/commands/eject.js +55 -0
- package/dist/cli/commands/events.d.ts +1 -0
- package/dist/cli/commands/events.js +49 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +62 -0
- package/dist/cli/commands/install-skill.d.ts +1 -0
- package/dist/cli/commands/install-skill.js +122 -0
- package/dist/cli/commands/list.d.ts +1 -0
- package/dist/cli/commands/list.js +13 -0
- package/dist/cli/commands/migrate.d.ts +1 -0
- package/dist/cli/commands/migrate.js +167 -0
- package/dist/cli/commands/phases.d.ts +1 -0
- package/dist/cli/commands/phases.js +25 -0
- package/dist/cli/commands/run.d.ts +3 -0
- package/dist/cli/commands/run.js +27 -0
- package/dist/cli/commands/sessions.d.ts +1 -0
- package/dist/cli/commands/sessions.js +20 -0
- package/dist/cli/commands/trace.d.ts +8 -0
- package/dist/cli/commands/trace.js +10 -0
- package/dist/cli/commands/ui.d.ts +1 -0
- package/dist/cli/commands/ui.js +35 -0
- package/dist/cli/commands/version.d.ts +1 -0
- package/dist/cli/commands/version.js +8 -0
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +210 -0
- package/dist/cli/gitignore.d.ts +1 -0
- package/dist/cli/gitignore.js +14 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +156 -0
- package/dist/core/agent_cc.d.ts +75 -0
- package/dist/core/agent_cc.js +322 -0
- package/dist/core/agent_flue.d.ts +66 -0
- package/dist/core/agent_flue.js +321 -0
- package/dist/core/agents.d.ts +64 -0
- package/dist/core/agents.js +456 -0
- package/dist/core/changes.d.ts +35 -0
- package/dist/core/changes.js +98 -0
- package/dist/core/console.d.ts +36 -0
- package/dist/core/console.js +156 -0
- package/dist/core/data_types.d.ts +562 -0
- package/dist/core/data_types.js +382 -0
- package/dist/core/gates.d.ts +33 -0
- package/dist/core/gates.js +144 -0
- package/dist/core/git_helper.d.ts +59 -0
- package/dist/core/git_helper.js +115 -0
- package/dist/core/issues/github_provider.d.ts +52 -0
- package/dist/core/issues/github_provider.js +211 -0
- package/dist/core/issues/provider.d.ts +90 -0
- package/dist/core/issues/provider.js +14 -0
- package/dist/core/paths.d.ts +78 -0
- package/dist/core/paths.js +108 -0
- package/dist/core/permissions.d.ts +78 -0
- package/dist/core/permissions.js +187 -0
- package/dist/core/prompts.d.ts +4 -0
- package/dist/core/prompts.js +17 -0
- package/dist/core/quality.d.ts +65 -0
- package/dist/core/quality.js +194 -0
- package/dist/core/runner.d.ts +72 -0
- package/dist/core/runner.js +168 -0
- package/dist/core/session.d.ts +16 -0
- package/dist/core/session.js +60 -0
- package/dist/core/sqlite.d.ts +55 -0
- package/dist/core/sqlite.js +106 -0
- package/dist/core/tracer.d.ts +56 -0
- package/dist/core/tracer.js +246 -0
- package/dist/core/utils.d.ts +40 -0
- package/dist/core/utils.js +108 -0
- package/dist/core/watch.d.ts +44 -0
- package/dist/core/watch.js +194 -0
- package/dist/test/agent_cc.test.d.ts +1 -0
- package/dist/test/agent_cc.test.js +95 -0
- package/dist/test/agent_flue.test.d.ts +1 -0
- package/dist/test/agent_flue.test.js +83 -0
- package/dist/test/data_types.test.d.ts +10 -0
- package/dist/test/data_types.test.js +49 -0
- package/dist/test/ui_server.test.d.ts +1 -0
- package/dist/test/ui_server.test.js +119 -0
- package/dist/test/watch.test.d.ts +1 -0
- package/dist/test/watch.test.js +227 -0
- package/dist/ui/server/app.d.ts +3 -0
- package/dist/ui/server/app.js +98 -0
- package/dist/ui/server/db.d.ts +82 -0
- package/dist/ui/server/db.js +333 -0
- package/dist/ui/server/serve.d.ts +13 -0
- package/dist/ui/server/serve.js +80 -0
- package/dist/ui/server/static.d.ts +5 -0
- package/dist/ui/server/static.js +55 -0
- package/dist/ui/shared/types.d.ts +263 -0
- package/dist/ui/shared/types.js +8 -0
- package/package.json +52 -0
- package/web/assets/index-C7nF068F.css +1 -0
- package/web/assets/index-mzSArcnQ.js +11 -0
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
- package/web/index.html +14 -0
- package/web/logo.svg +6 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code coding agent interface — a second backend alongside
|
|
3
|
+
* `agent_flue.ts` (`coding_agent: claude_code` in config), for running
|
|
4
|
+
* agents on the `claude` CLI instead of Flue. Subprocess-based, on purpose:
|
|
5
|
+
* the official SDK's own platform-specific optional dependency is 300MB+
|
|
6
|
+
* per platform, on top of the `claude` CLI itself (which anyone using this
|
|
7
|
+
* backend needs installed anyway) — shelling out costs SPF zero new
|
|
8
|
+
* dependencies, the same trade the pre-Flue `agent_pi.ts` made for `pi`.
|
|
9
|
+
*
|
|
10
|
+
* Every flag below was verified against a REAL local run of this exact
|
|
11
|
+
* machine's `claude` CLI (v2.1.237) before being written — not assumed from
|
|
12
|
+
* the SDK's docs, which describe a related but separately-versioned
|
|
13
|
+
* product. Findings that shaped this file:
|
|
14
|
+
*
|
|
15
|
+
* - `--json-schema` is real, and structured output arrives as a BUILT-IN
|
|
16
|
+
* tool call named `StructuredOutput` (visible in the `system/init`
|
|
17
|
+
* message's `tools` list the moment `--json-schema` is passed) — no
|
|
18
|
+
* custom tool registration needed, unlike Flue's injected `sf_report`.
|
|
19
|
+
* The final `result` message's `structured_output` field carries the
|
|
20
|
+
* validated object directly.
|
|
21
|
+
* - `--strict-mcp-config` (with no `--mcp-config` given) is REQUIRED, not
|
|
22
|
+
* optional: without it, a spawned `claude` process inherits this
|
|
23
|
+
* machine's entire user-level MCP server configuration (GitHub, Slack,
|
|
24
|
+
* Google Drive, whatever the operator has configured globally) — a real
|
|
25
|
+
* capability leak into what's supposed to be a `tools:`-bounded headless
|
|
26
|
+
* agent. `--tools ""` alone does NOT strip these; confirmed by a live
|
|
27
|
+
* run showing `mcp__github__*`/`mcp__claude_ai_*` tools still listed in
|
|
28
|
+
* `system/init` without it, and gone with it.
|
|
29
|
+
* - `--resume <uuid>` genuinely continues context (confirmed: a follow-up
|
|
30
|
+
* call answered from a file read in the FIRST call, never re-reading it)
|
|
31
|
+
* — the same continuity Flue's `init(agent, {id})` gives, so the
|
|
32
|
+
* same-session-correction design carries over unchanged.
|
|
33
|
+
* - The result message's `total_cost_usd` has no per-component breakdown
|
|
34
|
+
* (unlike Flue/pi-ai's `cost.{input,output,cacheRead,cacheWrite}`) — it's
|
|
35
|
+
* folded into `UsageBreakdown` under `total_cost` only, honestly, rather
|
|
36
|
+
* than fabricating a split CC doesn't provide.
|
|
37
|
+
*/
|
|
38
|
+
import { spawn } from "node:child_process";
|
|
39
|
+
import { createInterface } from "node:readline";
|
|
40
|
+
import { randomUUID } from "node:crypto";
|
|
41
|
+
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
42
|
+
import { UsageBreakdown, makeAgentResult } from "./data_types.js";
|
|
43
|
+
import { nowIso, operatorEnv } from "./utils.js";
|
|
44
|
+
const RESULT_SNIPPET_CHARS = 20_000;
|
|
45
|
+
const ARG_VALUE_CHARS = 20_000;
|
|
46
|
+
const LABEL_CHARS = 80;
|
|
47
|
+
const PRIMARY_ARGS = ["command", "path", "file_path", "pattern", "query", "url"];
|
|
48
|
+
function clipText(text, limit) {
|
|
49
|
+
return text.length <= limit ? text : text.slice(0, limit).trimEnd() + "…";
|
|
50
|
+
}
|
|
51
|
+
function labelFor(tool, args) {
|
|
52
|
+
let value = "";
|
|
53
|
+
for (const key of PRIMARY_ARGS) {
|
|
54
|
+
if (typeof args[key] === "string" && args[key].trim()) {
|
|
55
|
+
value = args[key];
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!value) {
|
|
60
|
+
for (const v of Object.values(args)) {
|
|
61
|
+
if (typeof v === "string" && v.trim()) {
|
|
62
|
+
value = v;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
value = String(value).split(/\s+/).filter(Boolean).join(" ");
|
|
68
|
+
return value ? `${tool}: ${clipText(value, LABEL_CHARS)}` : tool;
|
|
69
|
+
}
|
|
70
|
+
/** `tool_result.content` is a string in every observed case; defensive for the array-of-blocks form Anthropic's spec also allows. */
|
|
71
|
+
function snippetOf(content) {
|
|
72
|
+
if (content == null)
|
|
73
|
+
return "";
|
|
74
|
+
if (typeof content === "string")
|
|
75
|
+
return content;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.stringify(content);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return String(content);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Folds a `tool_use` content block (on an `assistant` message) + its
|
|
85
|
+
* matching `tool_result` block (on a LATER `user` message, linked by
|
|
86
|
+
* `tool_use_id`) into one record — the same shape
|
|
87
|
+
* `agent_flue.ToolCallTracker` produces, so `agents.ts`'s `eventForwarder`
|
|
88
|
+
* needs no backend-specific branching downstream of picking which tracker
|
|
89
|
+
* to use. `StructuredOutput` calls fold through here too, deliberately
|
|
90
|
+
* unfiltered — Flue's `sf_report` tool shows up as an ordinary `tool_call`
|
|
91
|
+
* event for the same reason, and that's useful trace information, not noise.
|
|
92
|
+
*/
|
|
93
|
+
export class CcToolCallTracker {
|
|
94
|
+
open = new Map();
|
|
95
|
+
observe(message) {
|
|
96
|
+
if (message.type === "assistant") {
|
|
97
|
+
for (const block of message.message?.content ?? []) {
|
|
98
|
+
if (block.type === "tool_use") {
|
|
99
|
+
this.open.set(block.id, {
|
|
100
|
+
tool: block.name,
|
|
101
|
+
args: block.input || {},
|
|
102
|
+
started_at: message.timestamp || nowIso(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (message.type === "user") {
|
|
109
|
+
for (const block of message.message?.content ?? []) {
|
|
110
|
+
if (block.type === "tool_result") {
|
|
111
|
+
return this.finish(block.tool_use_id, block.content, block.is_error !== true, message.timestamp);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
finish(callId, content, ok, endedAt) {
|
|
118
|
+
const opened = this.open.get(callId);
|
|
119
|
+
this.open.delete(callId);
|
|
120
|
+
const tool = opened?.tool || "tool";
|
|
121
|
+
const args = opened?.args || {};
|
|
122
|
+
const record = {
|
|
123
|
+
tool,
|
|
124
|
+
tool_call_id: callId,
|
|
125
|
+
args: Object.fromEntries(Object.entries(args).map(([key, value]) => [key, typeof value === "string" ? clipText(value, ARG_VALUE_CHARS) : value])),
|
|
126
|
+
ok,
|
|
127
|
+
label: labelFor(tool, args),
|
|
128
|
+
};
|
|
129
|
+
const snippet = snippetOf(content);
|
|
130
|
+
if (snippet)
|
|
131
|
+
record.result_snippet = clipText(snippet, RESULT_SNIPPET_CHARS);
|
|
132
|
+
record.ended_at = endedAt || nowIso();
|
|
133
|
+
if (opened?.started_at) {
|
|
134
|
+
record.started_at = opened.started_at;
|
|
135
|
+
const startMs = Date.parse(opened.started_at);
|
|
136
|
+
const endMs = Date.parse(record.ended_at);
|
|
137
|
+
if (!Number.isNaN(startMs) && !Number.isNaN(endMs))
|
|
138
|
+
record.duration_ms = Math.max(0, endMs - startMs);
|
|
139
|
+
}
|
|
140
|
+
return record;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ── tool-name resolution ─────────────────────────────────────────────────────
|
|
144
|
+
// SPF's canonical lowercase vocabulary -> Claude Code's own tool names.
|
|
145
|
+
// "ls" has no confirmed CC built-in (bash/glob cover it) — treated the same
|
|
146
|
+
// way agent_flue.ts treats it: a known name that resolves to nothing, not an
|
|
147
|
+
// unknown one, so a roster entry mentioning it fails nothing at validate time.
|
|
148
|
+
const TOOL_NAME_MAP = {
|
|
149
|
+
read: "Read",
|
|
150
|
+
write: "Write",
|
|
151
|
+
edit: "Edit",
|
|
152
|
+
bash: "Bash",
|
|
153
|
+
grep: "Grep",
|
|
154
|
+
glob: "Glob",
|
|
155
|
+
};
|
|
156
|
+
const TOOL_ALIASES = { find: "glob" };
|
|
157
|
+
const DROPPED_TOOLS = new Set(["ls"]);
|
|
158
|
+
export function isKnownToolName(name) {
|
|
159
|
+
return DROPPED_TOOLS.has(name) || (TOOL_ALIASES[name] ?? name) in TOOL_NAME_MAP;
|
|
160
|
+
}
|
|
161
|
+
/** `null`/`undefined` = every built-in tool (CC's own `--tools default`). `[]` = none (`--tools ""`). */
|
|
162
|
+
function toolsFlagValue(toolNames) {
|
|
163
|
+
if (!toolNames)
|
|
164
|
+
return "default";
|
|
165
|
+
const resolved = toolNames.filter((n) => !DROPPED_TOOLS.has(n)).map((n) => TOOL_NAME_MAP[TOOL_ALIASES[n] ?? n]).filter(Boolean);
|
|
166
|
+
return resolved.join(",");
|
|
167
|
+
}
|
|
168
|
+
// SPF's off|minimal|low|medium|high|xhigh|max -> CC's --effort low|medium|high|xhigh|max.
|
|
169
|
+
// CC has no "disabled reasoning" level for a headless run; off/minimal both
|
|
170
|
+
// round down to CC's floor rather than omitting --effort (which would fall
|
|
171
|
+
// through to CC's own default, "high" — the opposite of what "off" asked for).
|
|
172
|
+
const EFFORT_MAP = {
|
|
173
|
+
off: "low",
|
|
174
|
+
minimal: "low",
|
|
175
|
+
low: "low",
|
|
176
|
+
medium: "medium",
|
|
177
|
+
high: "high",
|
|
178
|
+
xhigh: "xhigh",
|
|
179
|
+
max: "max",
|
|
180
|
+
};
|
|
181
|
+
// ── process lifecycle ────────────────────────────────────────────────────────
|
|
182
|
+
const inFlight = new Set();
|
|
183
|
+
/** Kill any still-running `claude` children — call once, at process exit. Safe if none are running. */
|
|
184
|
+
export async function shutdown() {
|
|
185
|
+
for (const child of inFlight)
|
|
186
|
+
child.kill("SIGTERM");
|
|
187
|
+
inFlight.clear();
|
|
188
|
+
}
|
|
189
|
+
class CcRunError extends Error {
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Run one `claude -p` turn against `request.session_id` (a UUID minted by
|
|
193
|
+
* `agents.ts`'s `agentSessionId()` for a fresh session, or carried over from
|
|
194
|
+
* `agent_map.json` for a rejoin) — `--session-id` on first contact,
|
|
195
|
+
* `--resume` when `request.resume` says this session already exists. CC has
|
|
196
|
+
* no single create-or-continue flag the way Flue's `init(id)` is, which is
|
|
197
|
+
* exactly why `AgentRequest.resume` exists — see its doc comment in
|
|
198
|
+
* `data_types.ts`.
|
|
199
|
+
*
|
|
200
|
+
* `onEvent` receives each parsed stream-json line UNFOLDED, exactly as
|
|
201
|
+
* `agent_flue.run()` forwards raw `ConversationStreamChunk`s — folding into
|
|
202
|
+
* one record per tool call is `eventForwarder`'s job (`agents.ts`), via
|
|
203
|
+
* this module's `CcToolCallTracker`. Folding here too would fold twice.
|
|
204
|
+
*/
|
|
205
|
+
export async function run(request, onEvent, onSpawn, onExit) {
|
|
206
|
+
const args = [
|
|
207
|
+
"-p",
|
|
208
|
+
request.prompt,
|
|
209
|
+
"--output-format",
|
|
210
|
+
"stream-json",
|
|
211
|
+
"--verbose",
|
|
212
|
+
"--json-schema",
|
|
213
|
+
// `request.output_schema` is the raw Valibot schema object (which has its
|
|
214
|
+
// own internal `kind`/`type`/`entries` fields) — CC's `--json-schema`
|
|
215
|
+
// wants a real JSON Schema document, not that. Caught by a live run:
|
|
216
|
+
// stringifying the Valibot object directly produced "unknown keyword:
|
|
217
|
+
// kind" from CC's strict-mode validator.
|
|
218
|
+
JSON.stringify(toJsonSchema(request.output_schema)),
|
|
219
|
+
"--system-prompt",
|
|
220
|
+
request.system_prompt,
|
|
221
|
+
"--model",
|
|
222
|
+
request.model,
|
|
223
|
+
"--effort",
|
|
224
|
+
EFFORT_MAP[request.thinking],
|
|
225
|
+
request.resume ? "--resume" : "--session-id",
|
|
226
|
+
request.session_id,
|
|
227
|
+
"--permission-mode",
|
|
228
|
+
"bypassPermissions",
|
|
229
|
+
"--dangerously-skip-permissions",
|
|
230
|
+
"--tools",
|
|
231
|
+
toolsFlagValue(request.tools),
|
|
232
|
+
"--strict-mcp-config", // see the module doc comment — required, not optional
|
|
233
|
+
];
|
|
234
|
+
const child = spawn("claude", args, { cwd: request.cwd, env: operatorEnv() });
|
|
235
|
+
// The prompt travels as a positional argv element, not stdin — closing it
|
|
236
|
+
// immediately avoids a real, observed ~3s "no stdin data received" stall
|
|
237
|
+
// where `claude` otherwise waits to see whether anything is piped in.
|
|
238
|
+
child.stdin.end();
|
|
239
|
+
inFlight.add(child);
|
|
240
|
+
const pid = child.pid ?? -1;
|
|
241
|
+
onSpawn?.(pid);
|
|
242
|
+
const lines = createInterface({ input: child.stdout });
|
|
243
|
+
let result = null;
|
|
244
|
+
let lastTurnUsage;
|
|
245
|
+
let stderr = "";
|
|
246
|
+
child.stderr.on("data", (chunk) => {
|
|
247
|
+
stderr += String(chunk);
|
|
248
|
+
});
|
|
249
|
+
lines.on("line", (line) => {
|
|
250
|
+
if (!line.trim())
|
|
251
|
+
return;
|
|
252
|
+
let message;
|
|
253
|
+
try {
|
|
254
|
+
message = JSON.parse(line);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return; // a non-JSON stray line (shouldn't happen under stream-json) — not fatal
|
|
258
|
+
}
|
|
259
|
+
onEvent?.(message);
|
|
260
|
+
if (message.type === "assistant" && message.message?.usage)
|
|
261
|
+
lastTurnUsage = message.message.usage;
|
|
262
|
+
if (message.type === "result")
|
|
263
|
+
result = message;
|
|
264
|
+
});
|
|
265
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
266
|
+
child.on("error", reject); // spawn failure (e.g. claude not on PATH)
|
|
267
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
268
|
+
});
|
|
269
|
+
inFlight.delete(child);
|
|
270
|
+
onExit?.(pid);
|
|
271
|
+
if (exitCode !== 0 && !result) {
|
|
272
|
+
throw new CcRunError(`claude exited ${exitCode} before producing a result: ${stderr.slice(-2000) || "(no stderr)"}`);
|
|
273
|
+
}
|
|
274
|
+
if (!result) {
|
|
275
|
+
throw new CcRunError(`claude produced no result message (exit ${exitCode}): ${stderr.slice(-2000) || "(no stderr)"}`);
|
|
276
|
+
}
|
|
277
|
+
// A plain `const` handoff — `result` above is a closure-reassigned `let`,
|
|
278
|
+
// which TS's control-flow narrowing doesn't track across the `readline`
|
|
279
|
+
// callback boundary, so the null-check above alone doesn't narrow it.
|
|
280
|
+
const final = result;
|
|
281
|
+
if (final.is_error) {
|
|
282
|
+
const detail = final.result ?? (stderr.slice(-2000) || "no detail");
|
|
283
|
+
throw new CcRunError(`claude reported an error (${final.subtype ?? "unknown"}): ${detail}`);
|
|
284
|
+
}
|
|
285
|
+
const usage = new UsageBreakdown();
|
|
286
|
+
const u = final.usage ?? {};
|
|
287
|
+
const cacheRead = u.cache_read_input_tokens ?? 0;
|
|
288
|
+
const cacheWrite = u.cache_creation_input_tokens ?? 0;
|
|
289
|
+
const totalTokens = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + cacheRead + cacheWrite;
|
|
290
|
+
usage.add_turn({
|
|
291
|
+
input: u.input_tokens ?? 0,
|
|
292
|
+
output: u.output_tokens ?? 0,
|
|
293
|
+
cacheRead,
|
|
294
|
+
cacheWrite,
|
|
295
|
+
reasoning: u.output_tokens_details?.thinking_tokens ?? 0,
|
|
296
|
+
// CC gives one total cost, not a per-component breakdown like Flue/pi-ai
|
|
297
|
+
// do — folded honestly under `total`, not split up to fabricate one.
|
|
298
|
+
cost: { total: final.total_cost_usd ?? 0 },
|
|
299
|
+
}, totalTokens);
|
|
300
|
+
const modelUsages = final.modelUsage ?? {};
|
|
301
|
+
const contextWindow = Object.values(modelUsages)[0]?.contextWindow ?? 0;
|
|
302
|
+
const contextTokens = lastTurnUsage
|
|
303
|
+
? (lastTurnUsage.input_tokens ?? 0) +
|
|
304
|
+
(lastTurnUsage.output_tokens ?? 0) +
|
|
305
|
+
(lastTurnUsage.cache_read_input_tokens ?? 0) +
|
|
306
|
+
(lastTurnUsage.cache_creation_input_tokens ?? 0)
|
|
307
|
+
: 0;
|
|
308
|
+
return makeAgentResult({
|
|
309
|
+
session_id: final.session_id ?? request.session_id,
|
|
310
|
+
text: typeof final.result === "string" ? final.result : "",
|
|
311
|
+
report: final.structured_output ?? null,
|
|
312
|
+
tokens: usage.total_tokens,
|
|
313
|
+
cost: usage.total_cost,
|
|
314
|
+
usage,
|
|
315
|
+
context_tokens: contextTokens,
|
|
316
|
+
context_window: contextWindow,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** CC requires a real UUID for `--session-id`/`--resume` — `agents.ts`'s `agentSessionId()` calls this to mint a fresh one for a claude_code agent's first contact. */
|
|
320
|
+
export function newSessionId() {
|
|
321
|
+
return randomUUID();
|
|
322
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flue coding agent interface — replaces agent_pi.ts.
|
|
3
|
+
*
|
|
4
|
+
* One generic Flue agent function (`SfAgent`), driven entirely by YAML: a
|
|
5
|
+
* module-level registry maps a Flue conversation id (== SPF's session id) to
|
|
6
|
+
* the model/thinking/sandbox/tools/instruction that call should use, set by
|
|
7
|
+
* `run()` just before dispatching. SPF's roster stays data; Flue never learns
|
|
8
|
+
* there are five different "kinds" of agent.
|
|
9
|
+
*
|
|
10
|
+
* Structured output has no Flue-native "constrain this agent's final
|
|
11
|
+
* result" mechanism (that exists only for a nested `harness.prompt()` call
|
|
12
|
+
* inside a tool) — so every call gets one injected tool, `sf_report`, whose
|
|
13
|
+
* `input` schema IS the envelope's Valibot schema. Flue validates the
|
|
14
|
+
* model's arguments against it before `run()` fires, and `run()` returns
|
|
15
|
+
* `{terminate: true}` — the same loop-ending contract Flue's own built-in
|
|
16
|
+
* `finish`/`give_up` tools use — so a report ends the turn structurally,
|
|
17
|
+
* not just by instruction. The captured data rides back via
|
|
18
|
+
* `useDataWriter`, which is what `AgentReply.data` surfaces to the caller.
|
|
19
|
+
* `agents.ts`'s existing JSON-extraction fallback stays as a safety net for
|
|
20
|
+
* a model that never calls the tool at all.
|
|
21
|
+
*
|
|
22
|
+
* `run()`'s signature deliberately mirrors the old agent_pi.ts `run()` so
|
|
23
|
+
* agents.ts's `send()` closure changes only its imports and field names.
|
|
24
|
+
*/
|
|
25
|
+
import { type ConversationStreamChunk } from "@flue/runtime";
|
|
26
|
+
import type { AgentRequest, AgentResult } from "./data_types.ts";
|
|
27
|
+
/**
|
|
28
|
+
* Folds Flue's `tool-input` + `tool-output`/`tool-output-error` chunk pair
|
|
29
|
+
* into ONE normalized record per completed call — the same shape
|
|
30
|
+
* agent_pi.ts's tracker produced, so agents.ts's eventForwarder (and the
|
|
31
|
+
* tracer/visualizer downstream of it) need no changes. `tool-output` never
|
|
32
|
+
* carries the tool's name, which is exactly why folding is still required.
|
|
33
|
+
*/
|
|
34
|
+
export declare class ToolCallTracker {
|
|
35
|
+
private open;
|
|
36
|
+
/** Returns the record for a finished tool call, else null. */
|
|
37
|
+
observe(chunk: ConversationStreamChunk): Record<string, any> | null;
|
|
38
|
+
private finish;
|
|
39
|
+
}
|
|
40
|
+
/** Used by agents.validate() so a typo'd tool name fails before anything spawns. */
|
|
41
|
+
export declare function isKnownToolName(name: string): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Flue exposes no public catalog of registered models (only pi's --list-models
|
|
44
|
+
* CLI did, and that command is gone with pi). This checks only the STATIC
|
|
45
|
+
* `provider/model-id` shape — the same shape SPF's config docs already ask
|
|
46
|
+
* for — not that the provider is reachable or the id exists. An actually
|
|
47
|
+
* wrong model now surfaces at the first real dispatch instead of at
|
|
48
|
+
* validate() time; that trade is made explicit here rather than pretending
|
|
49
|
+
* a catalog check still happens.
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveModel(pattern: string): [string, string];
|
|
52
|
+
/** Graceful shutdown — call once, at process exit. Safe to call if never started. */
|
|
53
|
+
export declare function shutdown(): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Run one Flue dispatch+read turn against the given conversation id,
|
|
56
|
+
* creating it on first contact and continuing it on every later call with
|
|
57
|
+
* the same `session_id` — the same continuity `pi --session-id` gave.
|
|
58
|
+
*
|
|
59
|
+
* `onSpawn`/`onExit` bracket this call the way they bracketed pi's child
|
|
60
|
+
* process, but there is no child process here: Flue runs in-process, so
|
|
61
|
+
* both fire with this process's own pid. A future `spf abort` can still find
|
|
62
|
+
* and stop the right OS process; distinguishing WHICH in-flight submission
|
|
63
|
+
* that pid is running is a `processes.submission_id` column left for later,
|
|
64
|
+
* not attempted here.
|
|
65
|
+
*/
|
|
66
|
+
export declare function run(request: AgentRequest, onEvent?: (chunk: ConversationStreamChunk) => void, onSpawn?: (pid: number) => void, onExit?: (pid: number) => void): Promise<AgentResult>;
|