chore: remove unused code paths
Some checks are pending
sf self-deploy / build, test, and publish server image (push) Waiting to run
sf self-deploy / deploy test and probe (push) Blocked by required conditions
sf self-deploy / promote prod (push) Blocked by required conditions

This commit is contained in:
Mikael Hugo 2026-05-18 04:54:32 +02:00
parent 062e8e3c9f
commit 0c2e5ee256
13 changed files with 2 additions and 854 deletions

View file

@ -169,7 +169,6 @@
| types.ts | AI Providers | Core types for models, APIs, streaming options |
| env-api-keys.ts | AI Providers, Auth/OAuth | Environment variable API key resolution |
| web-runtime-env-api-keys.ts | AI Providers, Auth/OAuth | Web runtime API key handling |
| web-runtime-oauth.ts | AI Providers, Auth/OAuth | Web runtime OAuth token management |
| providers/register-builtins.ts | AI Providers | Registration of built-in provider implementations |
| providers/anthropic.ts | AI Providers | Anthropic API provider |
| providers/anthropic-shared.ts | AI Providers | Shared utilities for Anthropic provider variants |
@ -192,7 +191,6 @@
| providers/transform-messages.ts | AI Providers | Message transformation for provider compatibility |
| utils/oauth/index.ts | Auth/OAuth | OAuth utilities export hub |
| utils/oauth/types.ts | Auth/OAuth | OAuth credential and prompt types |
| utils/oauth/pkce.ts | Auth/OAuth | PKCE flow implementation |
| utils/oauth/github-copilot.ts | Auth/OAuth | GitHub Copilot OAuth flow |
| utils/oauth/google-oauth-utils.ts | Auth/OAuth | Shared Google OAuth utilities |
| utils/oauth/google-gemini-cli.ts | Auth/OAuth | Google Gemini CLI OAuth flow |
@ -373,10 +371,8 @@
| core/discovery-cache.ts | Model System | Model discovery result caching |
| core/keybindings.ts | TUI Components | Keybinding definitions |
| core/footer-data-provider.ts | TUI Components | Footer information provider |
| core/index.ts | Agent Core | Core module exports |
| index.ts | Agent Core | Package exports |
| utils/clipboard.ts | Tool System | Clipboard read/write |
| utils/clipboard-native.ts | Tool System | Native clipboard implementation |
| utils/clipboard-image.ts | Tool System | Clipboard image support |
| utils/error.ts | Agent Core | Error message extraction/formatting |
| utils/frontmatter.ts | Config | YAML frontmatter parsing |
@ -385,7 +381,6 @@
| utils/image-resize.ts | Image Processing | Image resizing and optimization |
| utils/mime.ts | Tool System | MIME type detection |
| utils/path-display.ts | TUI Components | Path formatting for display |
| utils/photon.ts | Agent Core | Photon scripting runtime support |
| utils/shell.ts | Tool System | Shell detection and execution |
| utils/changelog.ts | CLI | Changelog parsing |
| utils/sleep.ts | Agent Core | Async sleep/delay utility |

View file

@ -10,6 +10,7 @@
"**/*.d.ts",
"packages/coding-agent/src/core/export-html/**",
"packages/coding-agent/src/resources/extensions/**",
"packages/daemon/src/cli-dev.ts",
"scripts/tmp-check-test-imports/**",
"src/resources/extensions/**/dist/**",
"src/resources/extensions/**",
@ -28,7 +29,6 @@
"@mariozechner/jiti",
"@mistralai/mistralai",
"@octokit/rest",
"@silvia-odwyer/photon-node",
"@smithy/node-http-handler",
"@types/diff",
"@types/express",
@ -79,6 +79,7 @@
"src/headless.ts",
"src/headless*.ts",
"src/web-mode.ts",
"packages/daemon/src/cli-dev.ts",
"scripts/**/*.{js,cjs,mjs,ts}",
"tests/**/*.{js,cjs,mjs,ts}",
"src/tests/**/*.{js,cjs,mjs,ts}",

8
package-lock.json generated
View file

@ -27,7 +27,6 @@
"@mistralai/mistralai": "^2.2.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@octokit/rest": "^22.0.1",
"@silvia-odwyer/photon-node": "^0.3.4",
"@sinclair/typebox": "^0.34.49",
"@smithy/node-http-handler": "^4.7.3",
"@types/mime-types": "^3.0.1",
@ -5886,12 +5885,6 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@silvia-odwyer/photon-node": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz",
"integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==",
"license": "Apache-2.0"
},
"node_modules/@simple-git/args-pathspec": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz",
@ -15324,7 +15317,6 @@
"version": "2.75.4",
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@silvia-odwyer/photon-node": "^0.3.4",
"chalk": "^5.5.0",
"diff": "^9.0.0",
"express": "^5.2.1",

View file

@ -134,7 +134,6 @@
"@mistralai/mistralai": "^2.2.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@octokit/rest": "^22.0.1",
"@silvia-odwyer/photon-node": "^0.3.4",
"@sinclair/typebox": "^0.34.49",
"@smithy/node-http-handler": "^4.7.3",
"@types/mime-types": "^3.0.1",

View file

@ -1,37 +0,0 @@
/**
* PKCE utilities using Web Crypto API.
* Works in both Node.js 20+ and browsers.
*/
/**
* Encode bytes as base64url string.
*/
function base64urlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
/**
* Generate PKCE code verifier and challenge.
* Uses Web Crypto API for cross-platform compatibility.
*/
export async function generatePKCE(): Promise<{
verifier: string;
challenge: string;
}> {
// Generate random verifier
const verifierBytes = new Uint8Array(32);
crypto.getRandomValues(verifierBytes);
const verifier = base64urlEncode(verifierBytes);
// Compute SHA-256 challenge
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const challenge = base64urlEncode(new Uint8Array(hashBuffer));
return { verifier, challenge };
}

View file

@ -1,9 +0,0 @@
export {
getOAuthProvider,
getOAuthProviders,
type OAuthAuthInfo,
type OAuthCredentials,
type OAuthLoginCallbacks,
type OAuthPrompt,
type OAuthProviderInterface,
} from "./oauth.js";

View file

@ -21,7 +21,6 @@
},
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@silvia-odwyer/photon-node": "^0.3.4",
"chalk": "^5.5.0",
"diff": "^9.0.0",
"express": "^5.2.1",

View file

@ -1,78 +0,0 @@
/**
* Core modules shared between all run modes.
*/
export {
AgentSession,
type AgentSessionConfig,
type AgentSessionEvent,
type AgentSessionEventListener,
type ModelCycleResult,
type PromptOptions,
type SessionStats,
} from "./agent-session.js";
export {
type BashExecutorOptions,
type BashResult,
executeBash,
executeBashWithOperations,
} from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js";
export { ContextualTips, type TipContext } from "./contextual-tips.js";
export {
createEventBus,
type EventBus,
type EventBusController,
} from "./event-bus.js";
// Extensions system
export {
type AgentEndEvent,
type AgentStartEvent,
type AgentToolResult,
type AgentToolUpdateCallback,
type BeforeAgentStartEvent,
type ContextEvent,
discoverAndLoadExtensions,
type ExecOptions,
type ExecResult,
type Extension,
type ExtensionAPI,
type ExtensionCommandContext,
type ExtensionContext,
type ExtensionError,
type ExtensionEvent,
type ExtensionFactory,
type ExtensionFlag,
type ExtensionHandler,
type ExtensionManifest,
ExtensionRunner,
type ExtensionShortcut,
type ExtensionUIContext,
type LoadExtensionsResult,
type MessageRenderer,
type RegisteredCommand,
readManifest,
readManifestFromEntryPath,
type SessionBeforeCompactEvent,
type SessionBeforeForkEvent,
type SessionBeforeSwitchEvent,
type SessionBeforeTreeEvent,
type SessionCompactEvent,
type SessionForkEvent,
type SessionShutdownEvent,
type SessionStartEvent,
type SessionSwitchEvent,
type SessionTreeEvent,
type SortResult,
type SortWarning,
sortExtensionPaths,
type ToolCallEvent,
type ToolDefinition,
type ToolRenderResultOptions,
type ToolResultEvent,
type TurnEndEvent,
type TurnStartEvent,
wrapToolsWithExtensions,
} from "./extensions/index.js";
export { FallbackResolver, type FallbackResult } from "./fallback-resolver.js";

View file

@ -1,11 +0,0 @@
/**
* Re-export native clipboard utilities from "@singularity-forge/native.
*
* This module exists for backward compatibility. Prefer importing
* directly from "@singularity-forge/native/clipboard" in new code.
*/
export {
copyToClipboard,
readImageFromClipboard,
readTextFromClipboard,
} from "@singularity-forge/native/clipboard";

View file

@ -1,2 +0,0 @@
// Re-export types from the main package
export type { PhotonImage as PhotonImageType } from "@silvia-odwyer/photon-node";

View file

@ -1,313 +0,0 @@
import type { Server } from "node:http";
import {
type Context,
getModels,
type StreamOptions,
stream,
} from "@singularity-forge/ai";
import express from "express";
import type { AuthStorage } from "../core/auth-storage.js";
import type { ModelRegistry } from "../core/model-registry.js";
export type ProxyServerOptions = {
port: number;
authStorage: AuthStorage;
modelRegistry: ModelRegistry;
/** Per-family provider priority overrides from settings.proxy.providerPriority */
priorityOverrides?: Record<string, string[]>;
onLog?: (msg: string) => void;
};
// Per-family provider priority for bare model ID resolution. When the same model ID
// exists across multiple providers, the first matching family rule wins; within that
// rule providers are tried in order, preferring those with auth configured. Providers
// not listed in any rule fall back to insertion order.
const PROXY_FAMILY_PRIORITY: Array<{ match: RegExp; providers: string[] }> = [
// MiniMax: international direct > CN endpoint
{ match: /^MiniMax-/i, providers: ["minimax", "minimax-cn"] },
// GLM: zai is the canonical direct provider > opencode aggregators
{ match: /^glm-/i, providers: ["zai", "opencode", "opencode-go"] },
// Kimi: kimi-coding direct > opencode aggregators
{ match: /^kimi-/i, providers: ["kimi-coding", "opencode", "opencode-go"] },
// Gemini/Gemma: proxy bare model IDs through cli-core only.
{
match: /^gemini-|^gemma-/i,
providers: ["google-gemini-cli"],
},
// Claude: anthropic direct > opencode. Copilot is disabled.
{
match: /^claude-/i,
providers: ["anthropic", "opencode"],
},
// GPT/OpenAI: openai direct > azure. Copilot is disabled.
{
match: /^gpt-|^o[0-9]|^codex-/i,
providers: ["openai", "azure-openai-responses"],
},
];
function _sortByFamilyPriority<T extends { id: string; provider: string }>(
models: T[],
): T[] {
if (models.length <= 1) return models;
const [first] = models;
const rule = PROXY_FAMILY_PRIORITY.find((r) => r.match.test(first.id));
const order = rule?.providers ?? [];
return [...models].sort((a, b) => {
const pa = order.indexOf(a.provider);
const pb = order.indexOf(b.provider);
return (pa === -1 ? Infinity : pa) - (pb === -1 ? Infinity : pb);
});
}
export class ProxyServer {
private server: Server | null = null;
constructor(private options: ProxyServerOptions) {}
async start(): Promise<void> {
if (this.server) return;
const app = express();
app.use(express.json());
const { authStorage, modelRegistry, onLog } = this.options;
const priorityOverrides = this.options.priorityOverrides ?? {};
const log = (msg: string) => onLog?.(msg);
// 1. Model Listing
app.get(["/v1/models", "/v1beta/models"], async (req, res) => {
const providers = ["google-gemini-cli", "anthropic", "openai"];
const allModels = providers.flatMap((p) => getModels(p as any));
const formatted = allModels.map((m) => ({
id: m.id,
object: "model",
created: 1677610602,
owned_by: m.provider,
name: m.name,
capabilities: m.capabilities,
}));
if (req.path.startsWith("/v1beta")) {
res.json({ models: formatted });
} else {
res.json({ data: formatted, object: "list" });
}
});
// 2. Chat Completions (OpenAI & GenAI)
const handleChat = async (req: express.Request, res: express.Response) => {
const body = req.body;
const isOpenAi = req.path.includes("/v1/chat/completions");
const modelId = isOpenAi
? body.model
: req.params.modelId?.replace(/:streamGenerateContent$/, "");
if (!modelId) {
return res.status(400).json({ error: "Model ID is required" });
}
try {
const candidates = modelRegistry.getModelsForProxy(
modelId,
priorityOverrides,
);
if (candidates.length === 0) {
return res.status(404).json({ error: `Model ${modelId} not found` });
}
// Normalize messages once — shared across retry attempts
const context: Context = isOpenAi
? this.normalizeOpenAi(body)
: this.normalizeGoogle(body);
const streamOptions: StreamOptions = {
temperature: body.temperature,
maxTokens: isOpenAi
? body.max_tokens
: body.generationConfig?.maxOutputTokens,
};
for (const resolvedModel of candidates) {
const apiKey = await authStorage.getApiKey(resolvedModel.provider);
if (!apiKey) continue; // no credentials — try next
const streamOptionsWithKey: StreamOptions = {
...streamOptions,
apiKey,
};
try {
const eventStream = stream(
resolvedModel as any,
context,
streamOptionsWithKey as any,
);
if (body.stream) {
this.handleStreamingResponse(eventStream, res, isOpenAi, modelId);
} else {
await this.handleStaticResponse(
eventStream,
res,
isOpenAi,
modelId,
);
}
return; // success
} catch (err: any) {
const status = err?.status ?? err?.statusCode;
if (status === 429) {
log(
`Provider ${resolvedModel.provider} rate-limited (429), trying next candidate`,
);
continue;
}
throw err;
}
}
// All candidates exhausted
res
.status(429)
.json({ error: `All providers rate-limited for model ${modelId}` });
} catch (err: any) {
log(`Proxy error: ${err.message}`);
res.status(500).json({ error: err.message });
}
};
app.post("/v1/chat/completions", handleChat);
app.post("/v1beta/models/:modelId\\:streamGenerateContent", handleChat);
return new Promise((resolve) => {
this.server = app.listen(this.options.port, () => {
log(`Proxy Server running on http://localhost:${this.options.port}`);
resolve();
});
});
}
stop(): void {
if (this.server) {
this.server.close();
this.server = null;
}
}
private normalizeOpenAi(body: any): Context {
const messages = body.messages || [];
const system = messages.find((m: any) => m.role === "system")?.content;
const history = messages
.filter((m: any) => m.role !== "system")
.map((m: any) => ({
role: m.role === "user" ? "user" : "assistant",
content:
typeof m.content === "string"
? [{ type: "text", text: m.content }]
: m.content,
}));
return { messages: history, systemPrompt: system };
}
private normalizeGoogle(body: any): Context {
const contents = body.contents || [];
const history = contents.map((c: any) => ({
role: c.role === "user" ? "user" : "assistant",
content: (c.parts || []).map((p: any) => ({
type: "text",
text: p.text,
})),
}));
const system = body.systemInstruction?.parts?.[0]?.text;
return { messages: history, systemPrompt: system };
}
private handleStreamingResponse(
eventStream: any,
res: express.Response,
isOpenAi: boolean,
modelId: string,
) {
res.setHeader(
"Content-Type",
isOpenAi ? "text/event-stream" : "application/json",
);
eventStream.on("data", (ev: any) => {
if (ev.type === "text_delta") {
if (isOpenAi) {
const chunk = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [
{ index: 0, delta: { content: ev.delta }, finish_reason: null },
],
};
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
} else {
const chunk = {
candidates: [{ content: { parts: [{ text: ev.delta }] } }],
};
res.write(JSON.stringify(chunk) + "\n");
}
}
});
eventStream.on("done", () => {
if (isOpenAi) res.write("data: [DONE]\n\n");
res.end();
});
eventStream.on("error", (ev: any) => {
if (!res.headersSent)
res.status(500).json({ error: ev.error.errorMessage });
else res.end();
});
}
private async handleStaticResponse(
eventStream: any,
res: express.Response,
isOpenAi: boolean,
modelId: string,
) {
let fullContent = "";
eventStream.on("data", (ev: any) => {
if (ev.type === "text_delta") fullContent += ev.delta;
});
return new Promise<void>((resolve) => {
eventStream.on("done", () => {
if (isOpenAi) {
res.json({
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [
{
index: 0,
message: { role: "assistant", content: fullContent },
finish_reason: "stop",
},
],
});
} else {
res.json({
candidates: [{ content: { parts: [{ text: fullContent }] } }],
});
}
resolve();
});
eventStream.on("error", (ev: any) => {
res.status(500).json({ error: ev.error.errorMessage });
resolve();
});
});
}
}

View file

@ -1,5 +0,0 @@
/** Result of a process tree kill operation. */
export interface KillTreeResult {
/** Number of processes successfully killed. */
killed: number;
}

View file

@ -1,383 +0,0 @@
/**
* traces.ts Structured trace data model and export utilities for autonomous mode execution.
*
* Purpose: provide a lightweight, hierarchical span model that captures the
* full lifecycle of an autonomous mode session (session units tools) so that
* post-hoc analysis, debugging, and cost attribution can be done from a
* single JSON artifact instead of piecing together scattered logs.
*
* Consumer: headless.ts (creates and finalizes traces), trace-collector.ts
* (appends spans and events), and any external tool that reads .sf/traces/.
*/
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Classify the role of a span in the trace hierarchy.
*
* Purpose: distinguish session roots, milestone/slice/task units, and
* individual tool calls so that renderers and aggregators can group or
* filter spans by semantic category.
*
* Consumer: trace-collector.ts when creating spans, and trace visualizers
* that colour-code or collapse spans by kind.
*/
export type SpanKind = "session" | "unit" | "tool";
/**
* Terminal state of a span.
*
* Purpose: capture whether a span finished successfully, failed, was
* cancelled, or is still running so that trace consumers can compute
* success rates and identify hung operations.
*
* Consumer: trace-collector.ts on unit/tool end, and trace analysis scripts
* that aggregate outcomes across sessions.
*/
export type SpanStatus =
| "ok"
| "error"
| "cancelled"
| "timeout"
| "in_progress";
/**
* A discrete event attached to a span, such as a checkpoint or decision.
*
* Purpose: record semantically meaningful moments (e.g. "planning meeting
* started", "model switched") inside a span without creating a child span
* for every micro-step.
*
* Consumer: trace-collector.ts when recording model switches, gate results,
* or other non-span lifecycle events.
*/
export interface TraceEvent {
name: string;
timestamp: number;
attributes?: Record<string, string | number | boolean | null>;
}
/**
* Optional metadata attached to a span.
*
* Purpose: carry dimensional data (tokens, cost, model, file paths) that
* lets downstream tools attribute spend and latency to specific units or
* tools without parsing free-form log lines.
*
* Consumer: trace-collector.ts when enriching spans after LLM responses,
* and cost-dashboard scripts that sum inputTokens / outputTokens.
*/
export interface SpanAttributes {
// Session-level
projectRoot?: string;
sessionId?: string;
cwd?: string;
command?: string;
model?: string;
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
costUsd?: number;
exitCode?: number;
// Unit-level
unitType?: "milestone" | "slice" | "task";
unitId?: string;
unitStatus?: SpanStatus;
unitErrorReason?: string;
// Tool-level
toolName?: string;
toolCallId?: string;
toolStatus?: SpanStatus;
toolError?: string;
toolDurationMs?: number;
}
/**
* A single node in the trace tree.
*
* Purpose: represent one scoped operation (session, unit, or tool call) with
* timing, status, attributes, nested children, and a timeline of events so
* that the full execution graph can be reconstructed from the trace file.
*
* Consumer: trace-collector.ts, headless.ts, and any trace reader/visualizer.
*/
export interface Span {
id: string;
name: string;
kind: SpanKind;
status: SpanStatus;
startTime: number;
endTime?: number;
attributes: SpanAttributes;
children: Span[];
events: TraceEvent[];
}
/**
* The top-level trace container.
*
* Purpose: hold the root span and session metadata so that a single file
* contains everything needed to replay or analyse an autonomous mode session.
*
* Consumer: headless.ts (creates and finalizes), exportTrace/exportTraceToProject
* (serializes), and external trace consumers.
*/
export interface Trace {
id: string;
version: number;
projectRoot: string;
sessionId?: string;
startedAt: string;
completedAt?: string;
rootSpan: Span;
}
// ---------------------------------------------------------------------------
// Span helpers
// ---------------------------------------------------------------------------
/**
* Create a new span with a random UUID and current timestamp.
*
* Purpose: provide a single, correct construction site for spans so that
* every span has a stable ID and a consistent start-time baseline.
*
* Consumer: trace-collector.ts when starting a session, unit, or tool span.
*/
export function createSpan(
name: string,
kind: SpanKind,
attributes: SpanAttributes = {},
): Span {
return {
id: randomUUID(),
name,
kind,
status: "in_progress",
startTime: Date.now(),
attributes,
children: [],
events: [],
};
}
/**
* Mark a span as complete and record end time.
*
* Purpose: ensure every finished span carries both a terminal status and an
* end timestamp so that duration calculations and success-rate metrics are
* accurate.
*
* Consumer: trace-collector.ts when a unit or tool finishes.
*/
export function endSpan(span: Span, status: SpanStatus = "ok"): Span {
span.status = status;
span.endTime = Date.now();
return span;
}
/**
* Append a named event to a span with optional attributes.
*
* Purpose: let collectors record semantically rich checkpoints (model
* switches, gate completions) inside an existing span without mutating the
* span's own fields.
*
* Consumer: trace-collector.ts during autonomous mode phase transitions.
*/
export function addEvent(
span: Span,
name: string,
attributes?: Record<string, string | number | boolean | null>,
): void {
span.events.push({
name,
timestamp: Date.now(),
attributes,
});
}
/**
* Append an error event to a span with message and optional stack.
*
* Purpose: capture failure details (including stack traces when available)
* inside the trace so that debugging can be done from the trace file alone
* without cross-referencing separate log files.
*
* Consumer: trace-collector.ts when a tool call or unit throws.
*/
export function addError(span: Span, message: string, stack?: string): void {
span.events.push({
name: "error",
timestamp: Date.now(),
attributes: {
message,
...(stack ? { stack } : {}),
},
});
span.status = "error";
if (!span.endTime) span.endTime = Date.now();
}
// ---------------------------------------------------------------------------
// Trace helpers
// ---------------------------------------------------------------------------
/**
* Create a new trace with a root session span.
*
* Purpose: establish the top-level trace container and its root session span
* in one call so that headless.ts never creates a trace without a valid root.
*
* Consumer: headless.ts at the start of an autonomous mode session.
*/
export function createTrace(
projectRoot: string,
sessionId?: string,
command?: string,
model?: string,
): Trace {
const rootSpan = createSpan(`session:${sessionId ?? "unknown"}`, "session", {
sessionId,
projectRoot,
command,
model,
});
return {
id: randomUUID(),
version: 1,
projectRoot,
sessionId,
startedAt: new Date().toISOString(),
rootSpan,
};
}
/**
* Finalize a trace: set completedAt timestamp.
*
* Purpose: mark the trace as closed so that readers know the tree is
* complete and can safely compute session duration and aggregate costs.
*
* Consumer: headless.ts in the normal exit path and signal handlers.
*/
export function finalizeTrace(trace: Trace): Trace {
trace.completedAt = new Date().toISOString();
return trace;
}
/**
* Find a span in the tree by ID (linear walk).
*
* Purpose: let collectors locate an existing span (e.g. to attach a child
* or end it) without maintaining a separate ID-to-span map.
*
* Consumer: trace-collector.ts when bridging async tool-call results back
* to their original span.
*/
export function findSpan(span: Span, id: string): Span | undefined {
if (span.id === id) return span;
for (const child of span.children) {
const found = findSpan(child, id);
if (found) return found;
}
return undefined;
}
/**
* Add a child span to a parent.
*
* Purpose: build the hierarchical tree (session unit tool) so that
* trace readers can collapse, expand, or aggregate by level.
*
* Consumer: trace-collector.ts when starting a unit or tool inside an
* already-running parent span.
*/
export function addChildSpan(parent: Span, child: Span): void {
parent.children.push(child);
}
/**
* Walk all spans in a trace (root first, depth-first). Yields each span.
*
* Purpose: provide a simple, reusable traversal for aggregators, exporters,
* and debug printers that need to visit every span without writing recursive
* loops in every consumer.
*
* Consumer: trace analysis scripts, cost aggregators, and test assertions
* that verify span tree shape.
*/
export function* walkSpans(span: Span): Generator<Span, void, unknown> {
yield span;
for (const child of span.children) {
yield* walkSpans(child);
}
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
/**
* Serialize and write a trace to an arbitrary path.
* Creates parent directories as needed.
*
* Purpose: allow trace consumers (tests, CI scripts, manual debugging) to
* persist a trace anywhere on disk without hard-coding .sf/traces/ logic.
*
* Consumer: test suites that write traces to temp directories, and custom
* integrations that ship traces to external observability platforms.
*/
export function exportTrace(trace: Trace, path: string): void {
const dir = join(path, "..");
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(path, JSON.stringify(trace, null, 2), "utf-8");
}
/**
* Serialize and write a trace to .sf/traces/ in the project root.
* Filename: trace-<timestamp>.json
*
* Purpose: provide the standard, project-local trace sink so that every
* autonomous mode session leaves a discoverable artifact in a known location.
*
* Consumer: headless.ts in the normal exit path and signal handlers.
*/
export function exportTraceToProject(
trace: Trace,
projectRoot: string,
): string {
const tracesDir = join(projectRoot, ".sf", "traces");
if (!existsSync(tracesDir)) {
mkdirSync(tracesDir, { recursive: true });
}
const filename = `trace-${Date.now()}.json`;
const path = join(tracesDir, filename);
writeFileSync(path, JSON.stringify(trace, null, 2), "utf-8");
return path;
}
/**
* Read a trace from disk.
*
* Purpose: round-trip a trace file back into the typed model so that
* analysis tools, test assertions, and replay utilities can work with
* structured data instead of raw JSON.
*
* Consumer: trace analysis scripts, test helpers, and any tool that reads
* .sf/traces/ for post-session inspection.
*/
export function readTrace(path: string): Trace {
return JSON.parse(readFileSync(path, "utf-8")) as Trace;
}