@herbertgao/sol-pi 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.
Files changed (35) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +159 -0
  3. package/SECURITY.md +26 -0
  4. package/THIRD_PARTY_NOTICES.md +19 -0
  5. package/agents-install.md +150 -0
  6. package/assets/sol-pi-hero.png +0 -0
  7. package/docs/compatibility.md +67 -0
  8. package/docs/configuration.md +75 -0
  9. package/package.json +76 -0
  10. package/scripts/check-pi-compat.mjs +32 -0
  11. package/scripts/check-sol-pi-config.mjs +120 -0
  12. package/sol-pi.example.json +10 -0
  13. package/src/sol-pi/config.ts +135 -0
  14. package/src/sol-pi/extensions/action-fusion/file-queue.ts +71 -0
  15. package/src/sol-pi/extensions/action-fusion/index.ts +185 -0
  16. package/src/sol-pi/extensions/action-fusion/then-run.ts +128 -0
  17. package/src/sol-pi/extensions/evidence-preserving-reducer/archive.ts +53 -0
  18. package/src/sol-pi/extensions/evidence-preserving-reducer/candidate.ts +101 -0
  19. package/src/sol-pi/extensions/evidence-preserving-reducer/config.ts +71 -0
  20. package/src/sol-pi/extensions/evidence-preserving-reducer/index.ts +220 -0
  21. package/src/sol-pi/extensions/evidence-preserving-reducer/journal.ts +25 -0
  22. package/src/sol-pi/extensions/evidence-preserving-reducer/provider.ts +164 -0
  23. package/src/sol-pi/extensions/evidence-preserving-reducer/receipt.ts +177 -0
  24. package/src/sol-pi/extensions/observation-pack/index.ts +227 -0
  25. package/src/sol-pi/extensions/observation-pack/ledger.ts +20 -0
  26. package/src/sol-pi/extensions/observation-pack/observation.ts +252 -0
  27. package/src/sol-pi/extensions/online-context-compact/economics.ts +237 -0
  28. package/src/sol-pi/extensions/online-context-compact/extension.ts +455 -0
  29. package/src/sol-pi/extensions/online-context-compact/index.ts +49 -0
  30. package/src/sol-pi/extensions/online-context-compact/plan.ts +79 -0
  31. package/src/sol-pi/extensions/online-context-compact/state.ts +208 -0
  32. package/src/sol-pi/extensions/online-context-compact/tools.ts +100 -0
  33. package/src/sol-pi/index.ts +42 -0
  34. package/src/sol-pi/runtime-paths.ts +17 -0
  35. package/src/sol-pi/tui.ts +71 -0
@@ -0,0 +1,120 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+
9
+ const FEATURE_KEYS = [
10
+ "actionFusion",
11
+ "observationPack",
12
+ "evidencePreservingReducer",
13
+ "onlineContextCompact",
14
+ ];
15
+ const DEFAULT_CACHE_WRITE_READ_RATIO = 12.5;
16
+ const DEFAULT_EPR_REDUCER_PROVIDER = ["openai", "codex"].join("-");
17
+ const DEFAULT_EPR_REDUCER_MODEL = ["gpt-5.6", "luna"].join("-");
18
+ const STRING_KEYS = ["evidencePreservingReducerModel", "evidencePreservingReducerProvider"];
19
+ const CONFIG_KEYS = new Set(["version", ...FEATURE_KEYS, ...STRING_KEYS, "cacheWriteReadRatio"]);
20
+
21
+ function fail(message) {
22
+ throw new Error(message);
23
+ }
24
+
25
+ function parseArguments(argv) {
26
+ const options = { config: undefined, requireAllEnabled: false };
27
+ const seen = new Set();
28
+ for (let index = 0; index < argv.length; index += 1) {
29
+ const argument = argv[index];
30
+ if (seen.has(argument)) fail(`duplicate option: ${argument}`);
31
+ if (argument === "--require-all-enabled") {
32
+ seen.add(argument);
33
+ options.requireAllEnabled = true;
34
+ continue;
35
+ }
36
+ if (argument !== "--config") fail(`unknown option: ${argument}`);
37
+ seen.add(argument);
38
+ const value = argv[index + 1];
39
+ if (!value || value.startsWith("--")) fail("missing value for --config");
40
+ options.config = value;
41
+ index += 1;
42
+ }
43
+ if (!options.config) fail("--config is required");
44
+ return options;
45
+ }
46
+
47
+ function readConfig(path) {
48
+ let text;
49
+ try {
50
+ text = readFileSync(path, "utf8");
51
+ } catch (error) {
52
+ fail(`unable to read config ${path}: ${error instanceof Error ? error.message : String(error)}`);
53
+ }
54
+ try {
55
+ return JSON.parse(text);
56
+ } catch (error) {
57
+ fail(`invalid JSON in config ${path}: ${error instanceof Error ? error.message : String(error)}`);
58
+ }
59
+ }
60
+
61
+ function validateConfig(value, requireAllEnabled) {
62
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
63
+ fail("config must be a JSON object");
64
+ }
65
+ for (const key of Object.keys(value)) {
66
+ if (!CONFIG_KEYS.has(key)) fail(`unknown key: ${key}`);
67
+ }
68
+ if (value.version !== 1) fail("version must be 1");
69
+
70
+ const effective = { version: 1 };
71
+ for (const key of FEATURE_KEYS) {
72
+ const configured = value[key];
73
+ if (configured !== undefined && typeof configured !== "boolean") fail(`${key} must be boolean`);
74
+ effective[key] = configured ?? false;
75
+ if (requireAllEnabled && effective[key] !== true) fail(`${key} must be true`);
76
+ }
77
+ const cacheWriteReadRatio = Object.hasOwn(value, "cacheWriteReadRatio")
78
+ ? value.cacheWriteReadRatio
79
+ : DEFAULT_CACHE_WRITE_READ_RATIO;
80
+ if (
81
+ typeof cacheWriteReadRatio !== "number" ||
82
+ !Number.isFinite(cacheWriteReadRatio) ||
83
+ cacheWriteReadRatio < 0
84
+ ) {
85
+ fail("cacheWriteReadRatio must be a finite non-negative number");
86
+ }
87
+ effective.cacheWriteReadRatio = cacheWriteReadRatio;
88
+ effective.evidencePreservingReducerModel = stringConfigValue(
89
+ value,
90
+ "evidencePreservingReducerModel",
91
+ DEFAULT_EPR_REDUCER_MODEL,
92
+ );
93
+ effective.evidencePreservingReducerProvider = stringConfigValue(
94
+ value,
95
+ "evidencePreservingReducerProvider",
96
+ DEFAULT_EPR_REDUCER_PROVIDER,
97
+ );
98
+
99
+ return {
100
+ ok: true,
101
+ all_enabled: FEATURE_KEYS.every((key) => effective[key] === true),
102
+ effective_config: effective,
103
+ };
104
+ }
105
+
106
+ function stringConfigValue(value, key, defaultValue) {
107
+ const configured = Object.hasOwn(value, key) ? value[key] : defaultValue;
108
+ if (typeof configured !== "string" || configured.trim().length === 0) fail(`${key} must be a non-empty string`);
109
+ return configured;
110
+ }
111
+
112
+ try {
113
+ const options = parseArguments(process.argv.slice(2));
114
+ const configPath = resolve(options.config);
115
+ const result = validateConfig(readConfig(configPath), options.requireAllEnabled);
116
+ console.log(JSON.stringify({ ...result, config: configPath }, null, 2));
117
+ } catch (error) {
118
+ console.error(`SoL-Pi configuration preflight failed: ${error instanceof Error ? error.message : String(error)}`);
119
+ process.exitCode = 1;
120
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "actionFusion": false,
4
+ "observationPack": false,
5
+ "evidencePreservingReducer": false,
6
+ "evidencePreservingReducerProvider": "provider-id",
7
+ "evidencePreservingReducerModel": "model-id",
8
+ "onlineContextCompact": false,
9
+ "cacheWriteReadRatio": 12.5
10
+ }
@@ -0,0 +1,135 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ DEFAULT_REDUCER_MODEL,
11
+ DEFAULT_REDUCER_PROVIDER,
12
+ } from "./extensions/evidence-preserving-reducer/config.ts";
13
+
14
+ export const DEFAULT_CACHE_WRITE_READ_RATIO = 12.5;
15
+
16
+ export interface SolPiConfig {
17
+ readonly version: 1;
18
+ readonly actionFusion: boolean;
19
+ readonly observationPack: boolean;
20
+ readonly evidencePreservingReducer: boolean;
21
+ readonly evidencePreservingReducerModel: string;
22
+ readonly evidencePreservingReducerProvider: string;
23
+ readonly onlineContextCompact: boolean;
24
+ readonly cacheWriteReadRatio: number;
25
+ }
26
+
27
+ export const DEFAULT_CONFIG: SolPiConfig = Object.freeze({
28
+ version: 1,
29
+ actionFusion: false,
30
+ observationPack: false,
31
+ evidencePreservingReducer: false,
32
+ evidencePreservingReducerModel: DEFAULT_REDUCER_MODEL,
33
+ evidencePreservingReducerProvider: DEFAULT_REDUCER_PROVIDER,
34
+ onlineContextCompact: false,
35
+ cacheWriteReadRatio: DEFAULT_CACHE_WRITE_READ_RATIO,
36
+ });
37
+
38
+ const FEATURE_KEYS = [
39
+ "actionFusion",
40
+ "observationPack",
41
+ "evidencePreservingReducer",
42
+ "onlineContextCompact",
43
+ ] as const;
44
+ const STRING_KEYS = ["evidencePreservingReducerModel", "evidencePreservingReducerProvider"] as const;
45
+ const CONFIG_KEYS = new Set<string>(["version", ...FEATURE_KEYS, ...STRING_KEYS, "cacheWriteReadRatio"]);
46
+
47
+ export function findConfigPath(
48
+ cwd = process.cwd(),
49
+ agentDir = getAgentDir(),
50
+ allowProjectConfig = false,
51
+ ): string | undefined {
52
+ if (allowProjectConfig) {
53
+ const projectPath = join(cwd, CONFIG_DIR_NAME, "sol-pi.json");
54
+ if (existsSync(projectPath)) return projectPath;
55
+ }
56
+
57
+ const globalPath = join(agentDir, "sol-pi.json");
58
+ return existsSync(globalPath) ? globalPath : undefined;
59
+ }
60
+
61
+ export function loadSolPiConfig(
62
+ cwd = process.cwd(),
63
+ agentDir = getAgentDir(),
64
+ allowProjectConfig = false,
65
+ ): SolPiConfig {
66
+ const path = findConfigPath(cwd, agentDir, allowProjectConfig);
67
+ if (!path) return DEFAULT_CONFIG;
68
+
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(readFileSync(path, "utf8"));
72
+ } catch (error) {
73
+ const reason = error instanceof Error ? error.message : String(error);
74
+ throw new Error(`Unable to read SoL-Pi config ${path}: ${reason}`, { cause: error });
75
+ }
76
+
77
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
78
+ throw new Error(`SoL-Pi config must be a JSON object: ${path}`);
79
+ }
80
+
81
+ const record = parsed as Record<string, unknown>;
82
+ for (const key of Object.keys(record)) {
83
+ if (!CONFIG_KEYS.has(key)) throw new Error(`Unknown SoL-Pi config key: ${key}`);
84
+ }
85
+ if (record.version !== 1) throw new Error(`SoL-Pi config version must be 1: ${path}`);
86
+
87
+ for (const key of FEATURE_KEYS) {
88
+ if (record[key] !== undefined && typeof record[key] !== "boolean") {
89
+ throw new Error(`SoL-Pi config ${key} must be boolean: ${path}`);
90
+ }
91
+ }
92
+ const cacheWriteReadRatio = Object.hasOwn(record, "cacheWriteReadRatio")
93
+ ? record.cacheWriteReadRatio
94
+ : DEFAULT_CACHE_WRITE_READ_RATIO;
95
+ if (
96
+ typeof cacheWriteReadRatio !== "number" ||
97
+ !Number.isFinite(cacheWriteReadRatio) ||
98
+ cacheWriteReadRatio < 0
99
+ ) {
100
+ throw new Error(`SoL-Pi config cacheWriteReadRatio must be a finite non-negative number: ${path}`);
101
+ }
102
+ const evidencePreservingReducerModel = stringConfigValue(
103
+ record,
104
+ "evidencePreservingReducerModel",
105
+ DEFAULT_REDUCER_MODEL,
106
+ path,
107
+ );
108
+ const evidencePreservingReducerProvider = stringConfigValue(
109
+ record,
110
+ "evidencePreservingReducerProvider",
111
+ DEFAULT_REDUCER_PROVIDER,
112
+ path,
113
+ );
114
+
115
+ return Object.freeze({
116
+ ...DEFAULT_CONFIG,
117
+ ...record,
118
+ cacheWriteReadRatio,
119
+ evidencePreservingReducerModel,
120
+ evidencePreservingReducerProvider,
121
+ }) as SolPiConfig;
122
+ }
123
+
124
+ function stringConfigValue(
125
+ record: Record<string, unknown>,
126
+ key: (typeof STRING_KEYS)[number],
127
+ defaultValue: string,
128
+ path: string,
129
+ ): string {
130
+ const value = Object.hasOwn(record, key) ? record[key] : defaultValue;
131
+ if (typeof value !== "string" || value.trim().length === 0) {
132
+ throw new Error(`SoL-Pi config ${key} must be a non-empty string: ${path}`);
133
+ }
134
+ return value;
135
+ }
@@ -0,0 +1,71 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import { realpath } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { basename, dirname, resolve } from "node:path";
9
+
10
+ const queueTails = new Map<string, Promise<void>>();
11
+
12
+ function stripToolPathPrefix(filePath: string): string {
13
+ return filePath.startsWith("@") ? filePath.slice(1) : filePath;
14
+ }
15
+
16
+ export function resolveToolPath(cwd: string, filePath: string): string {
17
+ const expanded = stripToolPathPrefix(filePath);
18
+ if (expanded === "~") return homedir();
19
+ if (expanded.startsWith("~/")) return resolve(homedir(), expanded.slice(2));
20
+ return resolve(cwd, expanded);
21
+ }
22
+
23
+ function isMissingPathError(error: unknown): boolean {
24
+ return (
25
+ typeof error === "object" &&
26
+ error !== null &&
27
+ "code" in error &&
28
+ (error.code === "ENOENT" || error.code === "ENOTDIR")
29
+ );
30
+ }
31
+
32
+ async function canonicalQueueKey(filePath: string): Promise<string> {
33
+ const resolvedPath = resolve(filePath);
34
+ let current = resolvedPath;
35
+ const missingSegments: string[] = [];
36
+
37
+ while (true) {
38
+ try {
39
+ return resolve(await realpath(current), ...missingSegments);
40
+ } catch (error) {
41
+ if (!isMissingPathError(error)) throw error;
42
+ const parent = dirname(current);
43
+ if (parent === current) return resolvedPath;
44
+ missingSegments.unshift(basename(current));
45
+ current = parent;
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Serialize fused operations for one canonical file path. This queue belongs
52
+ * to SoL-Pi and intentionally does not nest Pi's built-in mutation queue.
53
+ */
54
+ export async function withFusedFileQueue<T>(filePath: string, work: () => Promise<T>): Promise<T> {
55
+ const key = await canonicalQueueKey(filePath);
56
+ const previous = queueTails.get(key) ?? Promise.resolve();
57
+ let release!: () => void;
58
+ const owned = new Promise<void>((resolveOwned) => {
59
+ release = resolveOwned;
60
+ });
61
+ const tail = previous.then(() => owned);
62
+ queueTails.set(key, tail);
63
+
64
+ await previous;
65
+ try {
66
+ return await work();
67
+ } finally {
68
+ release();
69
+ if (queueTails.get(key) === tail) queueTails.delete(key);
70
+ }
71
+ }
@@ -0,0 +1,185 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+ /**
6
+ * Action Fusion - fuse a file mutation and its follow-up command into one turn.
7
+ *
8
+ * Base pi rollouts repeatedly showed the same pair of turns: edit or write a
9
+ * file, then run a command to test, build, or start it. This extension replaces
10
+ * the built-in `edit` and `write` tools with versions that take an optional
11
+ * `then_run` object, apply the mutation, run the command, and return one
12
+ * combined observation. The model decision between the two turns disappears.
13
+ *
14
+ * Everything else about `edit` and `write` is inherited from the built-in
15
+ * definitions: their schemas, prompt text, argument shims, and renderers.
16
+ *
17
+ * This standalone version composes only Pi's public tool definitions.
18
+ */
19
+
20
+ import {
21
+ type BashToolOptions,
22
+ createEditToolDefinition,
23
+ createWriteToolDefinition,
24
+ type EditToolDetails,
25
+ type EditToolOptions,
26
+ type ExtensionAPI,
27
+ type ExtensionFactory,
28
+ type WriteToolOptions,
29
+ } from "@earendil-works/pi-coding-agent";
30
+ import { Type } from "typebox";
31
+ import { resolveToolPath } from "./file-queue.ts";
32
+ import { renderSolPiTool, showSolPiSavings } from "../../tui.ts";
33
+ import {
34
+ createThenRunSchema,
35
+ executeMutationThenRun,
36
+ THEN_RUN_SUCCEEDED,
37
+ type ThenRunInput,
38
+ } from "./then-run.ts";
39
+
40
+ const EDIT_THEN_RUN_DESCRIPTION =
41
+ "Command to run next on this file after the edit succeeds — e.g. run, build, start/restart, install, or check it; optional timeout in seconds. Skipped if the edit fails; a non-zero exit is reported but keeps the edit.";
42
+ const WRITE_THEN_RUN_DESCRIPTION =
43
+ "Command to run next on this file after the write succeeds — e.g. run, build, start/restart, install, or check it; optional timeout in seconds. Skipped if the write fails; a non-zero exit is reported but keeps the write.";
44
+
45
+ export interface ActionFusionOptions {
46
+ /** Optional programmatic bash overrides, primarily for tests and embedded runtimes. */
47
+ readonly bashOptions?: BashToolOptions;
48
+ /** Overrides for the underlying built-in `edit` tool. */
49
+ readonly editOptions?: EditToolOptions;
50
+ /** Overrides for the underlying built-in `write` tool. */
51
+ readonly writeOptions?: WriteToolOptions;
52
+ }
53
+
54
+ /**
55
+ * Built-in tool definitions capture their cwd in closures, so keep one per
56
+ * working directory instead of rebuilding them on every call and every redraw.
57
+ */
58
+ function memoizeByCwd<T>(create: (cwd: string) => T): (cwd: string) => T {
59
+ const cache = new Map<string, T>();
60
+ return (cwd) => {
61
+ const cached = cache.get(cwd);
62
+ if (cached) return cached;
63
+ const created = create(cwd);
64
+ cache.set(cwd, created);
65
+ return created;
66
+ };
67
+ }
68
+
69
+ function hasExternalToolOwner(pi: ExtensionAPI, name: string): boolean {
70
+ try {
71
+ const tool = pi.getAllTools().find((candidate) => candidate.name === name);
72
+ return Boolean(tool && tool.sourceInfo.source !== "builtin");
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ export function createActionFusionExtension(options: ActionFusionOptions = {}): ExtensionFactory {
79
+ const baseEdit = memoizeByCwd((cwd: string) => createEditToolDefinition(cwd, options.editOptions));
80
+ const baseWrite = memoizeByCwd((cwd: string) => createWriteToolDefinition(cwd, options.writeOptions));
81
+
82
+ return (pi: ExtensionAPI) => {
83
+ // then_run executes Bash inside the mutation tool, so it cannot pass through
84
+ // Pi's public tool_call permission handlers. Leave it to automode instead of
85
+ // creating a nested shell escape hatch.
86
+ if (hasExternalToolOwner(pi, "automode_inspect")) return;
87
+
88
+ const editTemplate = baseEdit(process.cwd());
89
+ const writeTemplate = baseWrite(process.cwd());
90
+
91
+ const editParameters = Type.Object({
92
+ ...editTemplate.parameters.properties,
93
+ then_run: createThenRunSchema(EDIT_THEN_RUN_DESCRIPTION),
94
+ });
95
+ const writeParameters = Type.Object({
96
+ ...writeTemplate.parameters.properties,
97
+ then_run: createThenRunSchema(WRITE_THEN_RUN_DESCRIPTION),
98
+ });
99
+
100
+ pi.registerTool<typeof editParameters, EditToolDetails | undefined>({
101
+ ...editTemplate,
102
+ parameters: editParameters,
103
+ async execute(toolCallId, input, signal, onUpdate, ctx) {
104
+ const { then_run, ...editInput } = input as typeof input & { then_run?: ThenRunInput };
105
+ const result = await executeMutationThenRun({
106
+ toolCallId,
107
+ absolutePath: resolveToolPath(ctx.cwd, input.path),
108
+ thenRun: then_run,
109
+ bashOptions: options.bashOptions,
110
+ signal,
111
+ ctx,
112
+ mutate: () => baseEdit(ctx.cwd).execute(toolCallId, editInput, signal, onUpdate, ctx),
113
+ });
114
+ if (
115
+ then_run &&
116
+ result.content.some((block) => block.type === "text" && block.text.includes(THEN_RUN_SUCCEEDED))
117
+ ) {
118
+ showSolPiSavings(ctx, "Action Fusion", "1 model round-trip avoided");
119
+ }
120
+ return result;
121
+ },
122
+ renderCall: (args, theme, context) => {
123
+ const base = baseEdit(context.cwd).renderCall!(args, theme, context);
124
+ return args.then_run ? renderSolPiTool(theme, "Action Fusion", "1 model round-trip avoided", base) : base;
125
+ },
126
+ renderResult: (result, resultOptions, theme, context) => {
127
+ const base = baseEdit(context.cwd).renderResult!(result, resultOptions, theme, context);
128
+ return context.args.then_run
129
+ ? renderSolPiTool(theme, "Action Fusion", "1 model round-trip avoided", base)
130
+ : base;
131
+ },
132
+ });
133
+
134
+ if (hasExternalToolOwner(pi, "write")) return;
135
+
136
+ pi.registerTool<typeof writeParameters, undefined>({
137
+ ...writeTemplate,
138
+ parameters: writeParameters,
139
+ async execute(toolCallId, input, signal, onUpdate, ctx) {
140
+ const { then_run, ...writeInput } = input as typeof input & { then_run?: ThenRunInput };
141
+ const result = await executeMutationThenRun({
142
+ toolCallId,
143
+ absolutePath: resolveToolPath(ctx.cwd, input.path),
144
+ thenRun: then_run,
145
+ bashOptions: options.bashOptions,
146
+ signal,
147
+ ctx,
148
+ mutate: () => baseWrite(ctx.cwd).execute(toolCallId, writeInput, signal, onUpdate, ctx),
149
+ });
150
+ if (
151
+ then_run &&
152
+ result.content.some((block) => block.type === "text" && block.text.includes(THEN_RUN_SUCCEEDED))
153
+ ) {
154
+ showSolPiSavings(ctx, "Action Fusion", "1 model round-trip avoided");
155
+ }
156
+ return result;
157
+ },
158
+ renderCall: (args, theme, context) => {
159
+ const base = baseWrite(context.cwd).renderCall!(args, theme, context);
160
+ return args.then_run ? renderSolPiTool(theme, "Action Fusion", "1 model round-trip avoided", base) : base;
161
+ },
162
+ renderResult: (result, resultOptions, theme, context) => {
163
+ const base = baseWrite(context.cwd).renderResult!(result, resultOptions, theme, context);
164
+ return context.args.then_run
165
+ ? renderSolPiTool(theme, "Action Fusion", "1 model round-trip avoided", base)
166
+ : base;
167
+ },
168
+ });
169
+ };
170
+ }
171
+
172
+ export type { ThenRunInput } from "./then-run.ts";
173
+ export {
174
+ assertUnchangedBeforeCommand,
175
+ executeMutationThenRun,
176
+ THEN_RUN_FAILED,
177
+ THEN_RUN_SKIPPED,
178
+ THEN_RUN_SUCCEEDED,
179
+ } from "./then-run.ts";
180
+
181
+ export function registerActionFusion(pi: ExtensionAPI): void {
182
+ createActionFusionExtension()(pi);
183
+ }
184
+
185
+ export default registerActionFusion;
@@ -0,0 +1,128 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ import { readFile } from "node:fs/promises";
7
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
8
+ import { type BashToolOptions, createBashToolDefinition, type ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "typebox";
10
+ import { withFusedFileQueue } from "./file-queue.ts";
11
+
12
+ export const THEN_RUN_SUCCEEDED = "[then_run:succeeded]";
13
+ export const THEN_RUN_FAILED = "[then_run:failed]";
14
+ export const THEN_RUN_SKIPPED = "[then_run:skipped]";
15
+
16
+ export interface ThenRunInput {
17
+ command: string;
18
+ timeout?: number;
19
+ }
20
+
21
+ export function createThenRunSchema(description: string) {
22
+ return Type.Optional(
23
+ Type.Object(
24
+ {
25
+ command: Type.String({ description: "Bash command to run" }),
26
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
27
+ },
28
+ { description },
29
+ ),
30
+ );
31
+ }
32
+
33
+ function errorText(error: unknown): string {
34
+ return error instanceof Error ? error.message : String(error);
35
+ }
36
+
37
+ function resultText(result: AgentToolResult<unknown>): string {
38
+ return result.content
39
+ .filter((block) => block.type === "text")
40
+ .map((block) => block.text)
41
+ .join("\n");
42
+ }
43
+
44
+ function thenRunSkippedError(error: unknown): Error {
45
+ return new Error(
46
+ `${errorText(error)}\n\n${THEN_RUN_SKIPPED} The file mutation did not complete successfully; the command was not run.`,
47
+ );
48
+ }
49
+
50
+ async function fileSha256(path: string): Promise<string> {
51
+ return createHash("sha256").update(await readFile(path)).digest("hex");
52
+ }
53
+
54
+ export async function assertUnchangedBeforeCommand(
55
+ path: string,
56
+ yieldForInterference: () => Promise<void> = () => new Promise<void>((resolve) => setImmediate(resolve)),
57
+ ): Promise<void> {
58
+ try {
59
+ const mutationHash = await fileSha256(path);
60
+ await yieldForInterference();
61
+ const commandHash = await fileSha256(path);
62
+ if (mutationHash !== commandHash) {
63
+ throw new Error("target content changed after the fused mutation");
64
+ }
65
+ } catch (error) {
66
+ throw new Error(`${THEN_RUN_SKIPPED} ${errorText(error)}; the command was not run.`, { cause: error });
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Apply a file mutation and, when the model asked for one, run its follow-up
72
+ * command before returning a single observation.
73
+ *
74
+ * Both steps run inside one SoL-Pi queue slot for `absolutePath`, so another
75
+ * fused mutation of the same file cannot interleave. Pi's built-in mutation
76
+ * tool keeps its own queue; the two queues are not nested.
77
+ */
78
+ export async function executeMutationThenRun<TDetails>({
79
+ toolCallId,
80
+ absolutePath,
81
+ thenRun,
82
+ mutate,
83
+ bashOptions,
84
+ signal,
85
+ ctx,
86
+ }: {
87
+ toolCallId: string;
88
+ absolutePath: string;
89
+ thenRun: ThenRunInput | undefined;
90
+ mutate: () => Promise<AgentToolResult<TDetails>>;
91
+ bashOptions: BashToolOptions | undefined;
92
+ signal: AbortSignal | undefined;
93
+ ctx: ExtensionContext;
94
+ }): Promise<AgentToolResult<TDetails>> {
95
+ return withFusedFileQueue(absolutePath, async () => {
96
+ let mutationResult: AgentToolResult<TDetails>;
97
+ try {
98
+ mutationResult = await mutate();
99
+ } catch (error) {
100
+ if (thenRun !== undefined) {
101
+ throw thenRunSkippedError(error);
102
+ }
103
+ throw error;
104
+ }
105
+
106
+ if (thenRun === undefined) {
107
+ return mutationResult;
108
+ }
109
+
110
+ await assertUnchangedBeforeCommand(absolutePath);
111
+ const bash = createBashToolDefinition(ctx.cwd, bashOptions);
112
+ try {
113
+ // pi-lens-ignore: sql-injection
114
+ const bashResult = await bash.execute(`${toolCallId}:then_run`, thenRun, signal, undefined, ctx);
115
+ const output = resultText(bashResult);
116
+ return {
117
+ ...mutationResult,
118
+ content: [
119
+ ...mutationResult.content,
120
+ { type: "text", text: output ? `${THEN_RUN_SUCCEEDED}\n${output}` : THEN_RUN_SUCCEEDED },
121
+ ],
122
+ };
123
+ } catch (error) {
124
+ const mutationOutput = resultText(mutationResult);
125
+ throw new Error([mutationOutput, THEN_RUN_FAILED, errorText(error)].filter(Boolean).join("\n\n"), { cause: error });
126
+ }
127
+ });
128
+ }