2026-03-10 22:28:37 -06:00
|
|
|
#!/usr/bin/env node
|
2026-04-29 12:42:31 +02:00
|
|
|
import {
|
|
|
|
|
cpSync,
|
|
|
|
|
existsSync,
|
|
|
|
|
mkdirSync,
|
|
|
|
|
readFileSync,
|
|
|
|
|
symlinkSync,
|
|
|
|
|
} from "node:fs";
|
2026-05-02 06:18:25 +02:00
|
|
|
import { delimiter, join, relative, resolve } from "node:path";
|
2026-05-02 12:39:27 +02:00
|
|
|
|
2026-04-15 13:38:15 +02:00
|
|
|
// SF Startup Loader
|
|
|
|
|
// Copyright (c) 2026 Singularity Forge
|
2026-03-15 14:33:43 -05:00
|
|
|
|
2026-05-02 06:55:12 +02:00
|
|
|
// Drop the node:sqlite ExperimentalWarning at the source so it never reaches
|
|
|
|
|
// stderr. Other ExperimentalWarnings (and all other warning classes) still
|
|
|
|
|
// propagate normally. node:sqlite has been experimental since v22 and the
|
|
|
|
|
// warning pollutes JSON output of `sf headless query` and similar commands.
|
|
|
|
|
{
|
|
|
|
|
const originalEmitWarning = process.emitWarning.bind(process);
|
|
|
|
|
type EmitArgs = Parameters<typeof process.emitWarning>;
|
|
|
|
|
process.emitWarning = ((...args: EmitArgs) => {
|
|
|
|
|
const [warning, typeOrOpts] = args;
|
|
|
|
|
const message = typeof warning === "string" ? warning : warning?.message;
|
|
|
|
|
const name =
|
|
|
|
|
typeof warning === "object" && warning !== null
|
|
|
|
|
? warning.name
|
|
|
|
|
: typeof typeOrOpts === "string"
|
|
|
|
|
? typeOrOpts
|
|
|
|
|
: typeof typeOrOpts === "object" && typeOrOpts !== null
|
|
|
|
|
? (typeOrOpts as { type?: string }).type
|
|
|
|
|
: undefined;
|
|
|
|
|
if (
|
|
|
|
|
name === "ExperimentalWarning" &&
|
|
|
|
|
typeof message === "string" &&
|
|
|
|
|
message.includes("SQLite is an experimental feature")
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
return originalEmitWarning(...args);
|
|
|
|
|
}) as typeof process.emitWarning;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 14:33:43 -05:00
|
|
|
// Fast-path: handle --version/-v and --help/-h before importing any heavy
|
|
|
|
|
// dependencies. This avoids loading the entire pi-coding-agent barrel import
|
|
|
|
|
// (~1s) just to print a version string.
|
2026-05-02 06:18:25 +02:00
|
|
|
const sfRootDir = resolve(import.meta.dirname, "..");
|
2026-04-29 12:42:31 +02:00
|
|
|
const args = process.argv.slice(2);
|
|
|
|
|
const firstArg = args[0];
|
2026-03-15 14:33:43 -05:00
|
|
|
|
2026-04-15 14:54:20 +02:00
|
|
|
// Read package.json once — reused for version, banner, and SF_VERSION below
|
2026-04-29 12:42:31 +02:00
|
|
|
let sfVersion = "0.0.0";
|
2026-03-17 22:57:13 -05:00
|
|
|
try {
|
2026-04-29 12:42:31 +02:00
|
|
|
const pkg = JSON.parse(
|
|
|
|
|
readFileSync(join(sfRootDir, "package.json"), "utf-8"),
|
|
|
|
|
);
|
|
|
|
|
sfVersion = pkg.version || "0.0.0";
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
2026-03-17 18:35:49 -06:00
|
|
|
|
2026-04-29 12:42:31 +02:00
|
|
|
if (firstArg === "--version" || firstArg === "-v") {
|
|
|
|
|
process.stdout.write(sfVersion + "\n");
|
|
|
|
|
process.exit(0);
|
2026-03-15 14:33:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:42:31 +02:00
|
|
|
if (firstArg === "--help" || firstArg === "-h") {
|
|
|
|
|
const { printHelp } = await import("./help-text.js");
|
|
|
|
|
printHelp(sfVersion);
|
|
|
|
|
process.exit(0);
|
2026-03-15 14:33:43 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 12:39:27 +02:00
|
|
|
if (
|
|
|
|
|
firstArg &&
|
|
|
|
|
firstArg !== "--" &&
|
|
|
|
|
args.slice(1).some((arg) => arg === "--help" || arg === "-h")
|
|
|
|
|
) {
|
|
|
|
|
const { printHelp, printSubcommandHelp } = await import("./help-text.js");
|
|
|
|
|
if (!printSubcommandHelp(firstArg, sfVersion)) {
|
|
|
|
|
printHelp(sfVersion);
|
|
|
|
|
}
|
|
|
|
|
process.exit(0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 11:34:41 +02:00
|
|
|
// Fast-path invalid headless invocations before importing cli.ts. These paths
|
|
|
|
|
// are commonly used by smoke tests and orchestrators; they should return a
|
|
|
|
|
// clear diagnostic without paying extension/resource startup cost.
|
|
|
|
|
if (firstArg === "headless") {
|
|
|
|
|
for (let i = 1; i < args.length; i += 1) {
|
|
|
|
|
const arg = args[i];
|
|
|
|
|
if (arg === "--timeout" && i + 1 < args.length) {
|
|
|
|
|
const timeout = parseInt(args[++i], 10);
|
|
|
|
|
if (Number.isNaN(timeout) || timeout < 0) {
|
|
|
|
|
process.stderr.write(
|
|
|
|
|
"[headless] Error: --timeout must be a non-negative integer (milliseconds, 0 to disable)\n",
|
|
|
|
|
);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (args.length === 1 && !existsSync(join(process.cwd(), ".sf"))) {
|
|
|
|
|
process.stderr.write(
|
|
|
|
|
"[headless] Error: No .sf/ directory found. Run 'sf headless init' or pass an explicit headless subcommand.\n",
|
|
|
|
|
);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 01:37:51 +02:00
|
|
|
// Schedule due-items banner — lightweight check before heavy imports
|
|
|
|
|
if (!process.env.SF_QUIET && firstArg !== "--version" && firstArg !== "-v" && firstArg !== "--help" && firstArg !== "-h") {
|
|
|
|
|
try {
|
|
|
|
|
const schedulePath = join(process.cwd(), ".sf", "schedule.jsonl");
|
|
|
|
|
if (existsSync(schedulePath)) {
|
|
|
|
|
const content = readFileSync(schedulePath, "utf-8");
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
let dueCount = 0;
|
|
|
|
|
for (const line of content.split("\n")) {
|
|
|
|
|
if (!line.trim()) continue;
|
|
|
|
|
try {
|
|
|
|
|
const entry = JSON.parse(line);
|
|
|
|
|
if (entry.status === "pending" && new Date(entry.due_at).getTime() <= now) {
|
|
|
|
|
dueCount++;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// skip corrupt lines
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (dueCount > 0) {
|
|
|
|
|
process.stderr.write(
|
|
|
|
|
`[forge] ${dueCount} scheduled item${dueCount === 1 ? "" : "s"} due now. Manage: /sf schedule list\n`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// non-fatal
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 09:43:54 -05:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Runtime dependency checks — fail fast with clear diagnostics before any
|
|
|
|
|
// heavy imports. Reads minimum Node version from the engines field in
|
|
|
|
|
// package.json (already parsed above) and verifies git is available.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
{
|
2026-05-02 06:18:25 +02:00
|
|
|
const MIN_NODE_MAJOR = 24;
|
2026-04-29 12:42:31 +02:00
|
|
|
const red = "\x1b[31m";
|
|
|
|
|
const bold = "\x1b[1m";
|
|
|
|
|
const dim = "\x1b[2m";
|
|
|
|
|
const reset = "\x1b[0m";
|
|
|
|
|
|
|
|
|
|
// -- Node version --
|
|
|
|
|
const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
|
|
|
|
|
if (nodeMajor < MIN_NODE_MAJOR) {
|
|
|
|
|
process.stderr.write(
|
|
|
|
|
`\n${red}${bold}Error:${reset} SF requires Node.js >= ${MIN_NODE_MAJOR}.0.0\n` +
|
|
|
|
|
` You are running Node.js ${process.versions.node}\n\n` +
|
|
|
|
|
`${dim}Install a supported version:${reset}\n` +
|
|
|
|
|
` nvm install ${MIN_NODE_MAJOR} ${dim}# if using nvm${reset}\n` +
|
|
|
|
|
` fnm install ${MIN_NODE_MAJOR} ${dim}# if using fnm${reset}\n` +
|
|
|
|
|
` brew install node@${MIN_NODE_MAJOR} ${dim}# macOS Homebrew${reset}\n\n`,
|
|
|
|
|
);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- git --
|
|
|
|
|
try {
|
|
|
|
|
const { execFileSync } = await import("node:child_process");
|
|
|
|
|
execFileSync("git", ["--version"], { stdio: "ignore" });
|
|
|
|
|
} catch {
|
|
|
|
|
process.stderr.write(
|
|
|
|
|
`\n${red}${bold}Error:${reset} SF requires git but it was not found on PATH.\n\n` +
|
|
|
|
|
`${dim}Install git:${reset}\n` +
|
|
|
|
|
` https://git-scm.com/downloads\n\n`,
|
|
|
|
|
);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}
|
2026-03-25 09:43:54 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 12:42:31 +02:00
|
|
|
import { agentDir, appRoot } from "./app-paths.js";
|
|
|
|
|
import { serializeBundledExtensionPaths } from "./bundled-extension-paths.js";
|
|
|
|
|
import { discoverExtensionEntryPaths } from "./extension-discovery.js";
|
|
|
|
|
import {
|
|
|
|
|
isExtensionEnabled,
|
|
|
|
|
loadRegistry,
|
|
|
|
|
readManifestFromEntryPath,
|
|
|
|
|
} from "./extension-registry.js";
|
|
|
|
|
import { renderLogo } from "./logo.js";
|
|
|
|
|
import { applyRtkProcessEnv } from "./rtk.js";
|
2026-03-10 22:28:37 -06:00
|
|
|
|
2026-04-15 15:37:12 +02:00
|
|
|
// pkg/ is a shim directory: contains sf's piConfig (package.json) and pi's
|
2026-03-10 22:28:37 -06:00
|
|
|
// theme assets (dist/modes/interactive/theme/) without a src/ directory.
|
|
|
|
|
// This allows config.js to:
|
2026-04-15 13:38:15 +02:00
|
|
|
// 1. Read piConfig.name → "sf" (branding)
|
2026-03-10 22:28:37 -06:00
|
|
|
// 2. Resolve themes via dist/ (no src/ present → uses dist path)
|
2026-05-02 06:18:25 +02:00
|
|
|
const pkgDir = resolve(import.meta.dirname, "..", "pkg");
|
2026-03-10 22:28:37 -06:00
|
|
|
|
|
|
|
|
// MUST be set before any dynamic import of pi SDK fires — this is what config.js
|
|
|
|
|
// reads to determine APP_NAME and CONFIG_DIR_NAME
|
2026-04-29 12:42:31 +02:00
|
|
|
process.env.PI_PACKAGE_DIR = pkgDir;
|
|
|
|
|
process.env.PI_SKIP_VERSION_CHECK = "1"; // SF runs its own update check in cli.ts — suppress pi's
|
|
|
|
|
process.title = "sf";
|
2026-03-10 22:28:37 -06:00
|
|
|
|
2026-04-15 15:37:12 +02:00
|
|
|
// Print branded banner on first launch (before ~/.sf/ exists).
|
2026-04-15 14:54:20 +02:00
|
|
|
// Set SF_FIRST_RUN_BANNER so cli.ts skips the duplicate welcome screen.
|
2026-03-12 15:18:06 -06:00
|
|
|
if (!existsSync(appRoot)) {
|
2026-04-29 12:42:31 +02:00
|
|
|
const cyan = "\x1b[36m";
|
|
|
|
|
const green = "\x1b[32m";
|
|
|
|
|
const dim = "\x1b[2m";
|
|
|
|
|
const reset = "\x1b[0m";
|
|
|
|
|
const colorCyan = (s: string) => `${cyan}${s}${reset}`;
|
|
|
|
|
process.stderr.write(
|
|
|
|
|
renderLogo(colorCyan) +
|
|
|
|
|
"\n" +
|
|
|
|
|
` Singularity Forge ${dim}v${sfVersion}${reset}\n` +
|
|
|
|
|
` ${green}Welcome.${reset} Setting up your environment...\n\n`,
|
|
|
|
|
);
|
|
|
|
|
process.env.SF_FIRST_RUN_BANNER = "1";
|
2026-03-12 15:18:06 -06:00
|
|
|
}
|
2026-03-11 01:16:40 -06:00
|
|
|
|
2026-04-15 15:37:12 +02:00
|
|
|
// SF_CODING_AGENT_DIR — tells pi's getAgentDir() to return ~/.sf/agent/ instead of ~/.sf/agent/
|
2026-04-29 12:42:31 +02:00
|
|
|
process.env.SF_CODING_AGENT_DIR = agentDir;
|
2026-03-10 22:28:37 -06:00
|
|
|
|
2026-04-15 14:54:20 +02:00
|
|
|
// SF_PKG_ROOT — absolute path to sf-run package root. Used by deployed extensions
|
2026-04-10 19:57:06 -05:00
|
|
|
// (e.g. auto.ts resume path) to import modules like resource-loader.js that live
|
2026-04-15 15:37:12 +02:00
|
|
|
// in the package tree, not in the deployed ~/.sf/agent/ tree.
|
2026-04-29 12:42:31 +02:00
|
|
|
process.env.SF_PKG_ROOT = sfRootDir;
|
2026-04-10 19:57:06 -05:00
|
|
|
|
2026-04-15 15:37:12 +02:00
|
|
|
// RTK environment — make ~/.sf/agent/bin visible to all child-process paths,
|
2026-04-15 14:54:20 +02:00
|
|
|
// not just the bash tool, and force-disable RTK telemetry for SF-managed use.
|
2026-04-29 12:42:31 +02:00
|
|
|
applyRtkProcessEnv(process.env);
|
feat: managed RTK integration with opt-in preference and web UI toggle (#2620)
* feat: integrate managed RTK across shell workflows
* fix(rtk): unify managed fallback and live savings wiring
* fix(rtk): improve TUI status visibility
* fix(tests): make portability tests independent of pi-coding-agent dist build
The CI portability test runs don't guarantee that
packages/pi-coding-agent has been compiled. Any test that
imported files pulling in @gsd/pi-coding-agent (resource-loader,
preferences-skills, async-bash-tool, etc.) crashed with
ERR_MODULE_NOT_FOUND pointing at dist/index.js.
Two changes to dist-redirect.mjs (the Node ESM loader hook used by
all unit tests):
- Redirect the bare @gsd/pi-coding-agent specifier to the workspace
source entrypoint (src/index.ts) so no dist/ artifact is needed.
- Extend the load() hook to transpile *.ts files under
packages/pi-coding-agent/src/ through TypeScript's transpileModule.
Node's --experimental-strip-types can't handle parameter properties
and similar syntax present in that package's source; full transpilation
avoids the ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX crash.
Also fix the dashboard.tsx responsive grid:
- xl:grid-cols-5 → xl:grid-cols-4 2xl:grid-cols-5
(5 metric cards no longer fit at xl without overflow; test contract
expected xl:grid-cols-4)
- Keep loading-skeletons.tsx in sync with the same breakpoints.
Add src/tests/resolve-ts-loader.test.ts to guard the loader behaviour:
- bare @gsd/pi-coding-agent redirect points to workspace source
- direct source-entry rewrite (.js → .ts)
- transpilation removes TS parameter property syntax that strip-only
mode cannot parse
* fix(tests): redirect all workspace package imports to source in portability tests
The previous fix only redirected @gsd/pi-coding-agent to its
source entrypoint. In CI, pi-coding-agent/src itself imports
@gsd/pi-ai (and other workspace packages) which were still pointing
at dist/. Since no workspace dist is built during the portability
test run, any transitive resolution hit the same ERR_MODULE_NOT_FOUND.
Changes to dist-redirect.mjs:
- Redirect @gsd/pi-ai, @gsd/pi-ai/oauth, @gsd/pi-agent-core, and
@gsd/pi-tui bare imports to their workspace src/ entrypoints.
- Broaden the load() transpilation condition from
'/packages/pi-coding-agent/src/' to '/packages/*/src/' so that
all workspace source files are run through TypeScript's
transpileModule, handling parameter properties and other syntax
that Node's strip-only mode rejects.
Verified by hiding all four workspace dist/ directories locally and
running the failing test set — 96/96 pass.
* fix(tests): redirect @gsd/native sub-paths; fix Windows .cmd spawnSync
Two more portability failures after the previous fix:
1. @gsd/native sub-path imports (@gsd/native/fd, @gsd/native/text, etc.)
were not redirected — the loader only handled the bare specifier.
Added a prefix-match redirect for @gsd/native/* → packages/native/src/<sub>/index.ts.
2. Windows RTK tests failed because createFakeRtk produces a .cmd wrapper
on Windows, and spawnSync(binaryPath, [...]) without shell:true silently
returns non-zero when the binary is a .cmd file.
Added shell: /\.(cmd|bat)$/i.test(binaryPath) to the spawnSync calls in:
- src/resources/extensions/shared/rtk.ts (rewriteCommandWithRtk)
- src/resources/extensions/shared/rtk-session-stats.ts (readCurrentRtkGainSummary)
- packages/pi-coding-agent/src/utils/rtk.ts (rewriteCommandForGsd)
Production use of rtk.exe is unaffected; the shell flag is only true for
.cmd/.bat paths.
Verified: all 93 portability tests pass with all workspace dist/ directories
removed (simulating CI portability environment).
* fix(tests): Windows portability fixes — HOME env, managed RTK path, perf threshold
Four Windows-specific failures fixed:
1. app-smoke.test.ts: process.env.HOME is undefined on Windows (uses
USERPROFILE instead). Changed to homedir() from node:os which works
cross-platform.
2. Managed RTK path tests on Windows: tests placed a fake RTK as rtk.exe
(by copying a .cmd script into a .exe filename), which Windows cannot
execute. Two-part fix:
- resolveRtkBinaryPath() in both rtk.ts files now falls back to rtk.cmd
in the managed dir on Windows when rtk.exe is absent.
- withManagedFakeRtk and equivalent patterns in rtk.test.ts,
rtk-session-stats.test.ts, rtk-execution-seams.test.ts changed to
place the fake at rtk.cmd instead of rtk.exe on Windows.
3. bg_shell RTK test on Windows: requires bash (for shell sessions), which
is not available on the blacksmith-4vcpu-windows-2025 runner without
Git Bash installed. Test now skips on win32.
4. derive-state-db perf assertion: 10ms threshold was too tight for Windows
CI runners (measured 12ms under load). Raised to 25ms — still catches
real regressions (baseline is 3ms locally and ~12ms on stressed runners).
* fix(tests): fix managed RTK path fallback on Windows in src/rtk.ts + fix copyable fake
Two remaining Windows failures:
1. src/rtk.ts was never patched with the rtk.cmd managed-dir fallback
(only the shared/rtk.ts and pi-coding-agent/src/utils/rtk.ts were updated).
Added the same rtk.cmd fallback and shell:.cmd detection to src/rtk.ts,
which is what rtk.test.ts imports from.
2. createFakeRtk on Windows wrote '%~dp0\fake-rtk.js' in the .cmd content —
this resolves relative to the .cmd file's own directory. When the test
copies rtk.cmd to a different managed dir, %~dp0 resolves to the copy
destination where fake-rtk.js does not exist. Fixed by embedding the
absolute path to fake-rtk.js directly in the .cmd content so the fake
works correctly regardless of where the .cmd is copied.
* feat(experimental): add RTK opt-in preference with web UI toggle
- Add `experimental` category to GSDPreferences with `rtk: boolean` (default: false)
- RTK is now opt-in: disabled by default for all projects unless explicitly enabled
- Validate experimental.* keys; unknown experimental keys produce warnings
Web UI:
- Add ExperimentalPanel component with animated toggle switch per flag
- Add /api/experimental route (GET/PATCH) to read/write flags in preferences.md
- Add 'Experimental' tab to settings dialog sidebar nav (FlaskConical icon)
- Include ExperimentalPanel at bottom of gsd-prefs mega-scroll
- Fix toggle disabled state: trigger loadSettingsData for 'experimental' section
and self-fetch on mount when data is absent
Dashboard:
- Gate RTK Saved metric card on rtkEnabled from live auto state (web)
- Gate TUI dashboard RTK savings row on rtkEnabled
- Gate TUI footer RTK status updates on experimental.rtk preference
- Propagate rtkEnabled through AutoDashboardData → bridge-service → store
Build:
- Add scripts/build-if-stale.cjs: incremental build driver that skips each
step (packages, root tsc, copy-resources, web) when output is newer than
source; replaces full rebuild chain in gsd:web
- Add scripts/web-stop.cjs: robust stop with registry + legacy PID + orphan
sweep via pgrep; handles crash/restart orphaned next-server processes
- gsd:web now uses build-if-stale.cjs (fast cold starts, instant when unchanged)
- gsd:web:stop / gsd:web:stop:all use web-stop.cjs directly
Fix: correct import path in rtk-status.ts (./preferences.js not ../preferences.js)
* fix: restore em-dash encoding in package.json to match upstream
* refactor(rtk): move command rewrite out of pi-coding-agent into GSD extension
Per review feedback from igouss: pi-coding-agent should not be modified to add
GSD-specific logic. Instead, add a proper extension point and wire RTK through it.
Changes to packages/pi-coding-agent (extension API only — no RTK logic):
- Add BashTransformEvent + BashTransformEventResult types to extension API
- Add on('bash_transform') overload to ExtensionAPI interface
- Add emitBashTransform() to ExtensionRunner (chains all handlers in order)
- Call emitBashTransform() in wrapToolWithExtensions before bash tool execution
- Export new types from extensions/index.ts and package index.ts
- Revert all RTK-specific changes from bash-executor.ts, tools/bash.ts
- Remove packages/pi-coding-agent/src/utils/rtk.ts entirely
Changes to GSD extension:
- Register bash_transform handler in register-hooks.ts that calls
rewriteCommandWithRtk() from the existing shared/rtk.ts module
- Handler is a no-op when RTK is disabled or not installed
* fix: correct import path for shared/rtk.js in register-hooks
* fix(tests): remove deleted pi-coding-agent/utils/rtk imports from execution seams test
The RTK rewrite logic was moved out of pi-coding-agent into the GSD
extension (bash_transform hook). Tests that directly imported the
deleted utils/rtk.ts are removed; remaining tests verify the shared
RTK module and GSD-layer surfaces that still call rewriteCommandWithRtk.
2026-03-26 08:33:07 -07:00
|
|
|
|
2026-04-15 15:37:12 +02:00
|
|
|
// NODE_PATH — make sf's own node_modules available to extensions loaded via jiti.
|
2026-03-10 22:28:37 -06:00
|
|
|
// Without this, extensions (e.g. browser-tools) can't resolve dependencies like
|
2026-04-15 15:37:12 +02:00
|
|
|
// `playwright` because jiti resolves modules from pi-coding-agent's location, not sf's.
|
|
|
|
|
// Prepending sf's node_modules to NODE_PATH fixes this for all extensions.
|
2026-04-29 12:42:31 +02:00
|
|
|
const sfNodeModules = join(sfRootDir, "node_modules");
|
2026-04-15 18:33:47 +02:00
|
|
|
process.env.NODE_PATH = [sfNodeModules, process.env.NODE_PATH]
|
2026-04-29 12:42:31 +02:00
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(delimiter);
|
2026-03-10 22:28:37 -06:00
|
|
|
// Force Node to re-evaluate module search paths with the updated NODE_PATH.
|
|
|
|
|
// Must happen synchronously before cli.js imports → extension loading.
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
2026-04-29 12:42:31 +02:00
|
|
|
const { Module } = await import("node:module");
|
|
|
|
|
(Module as any)._initPaths?.();
|
2026-03-10 22:28:37 -06:00
|
|
|
|
2026-04-15 14:54:20 +02:00
|
|
|
// SF_VERSION — expose package version so extensions can display it
|
2026-04-29 12:42:31 +02:00
|
|
|
process.env.SF_VERSION = sfVersion;
|
2026-03-10 22:28:37 -06:00
|
|
|
|
2026-04-15 14:54:20 +02:00
|
|
|
// SF_BIN_PATH — absolute path to this loader (dist/loader.js), used by patched subagent
|
2026-04-15 15:37:12 +02:00
|
|
|
// to spawn sf instead of pi when dispatching workflow tasks.
|
|
|
|
|
// Respect a pre-set value so a source-mode wrapper (e.g. bin/sf-from-source) can
|
2026-04-15 11:01:18 +02:00
|
|
|
// advertise the executable shim instead of the .ts loader path (which spawn() can't exec).
|
2026-04-29 12:42:31 +02:00
|
|
|
process.env.SF_BIN_PATH = process.env.SF_BIN_PATH || process.argv[1];
|
2026-03-10 22:28:37 -06:00
|
|
|
|
2026-04-15 15:37:12 +02:00
|
|
|
// SF_WORKFLOW_PATH — absolute path to bundled SF-WORKFLOW.md, used by patched sf extension
|
fix: read resources from dist/ to prevent branch-drift in npm-link setups (#314)
* fix: read resources from dist/ to prevent branch-drift in npm-link setups
initResources() reads extensions, prompts, skills, and agents from
src/resources/ — which points into the live working tree when gsd is
installed via npm link. Switching branches in the gsd repo changes
src/resources/ for ALL projects using gsd, causing stale or broken
extensions to be synced to ~/.gsd/agent/ on next launch.
Fix: the build step now copies src/resources/ to dist/resources/.
At runtime, resource-loader.ts and loader.ts prefer dist/resources/
(stable, set at build time) over src/resources/ (live working tree).
Fallback to src/resources/ is preserved for setups without a build.
Also adds npm run dev watch-resources watcher that syncs src/resources/
to dist/resources/ on file changes, running alongside tsc --watch.
* fix: cache prompt templates per session to prevent cross-session invalidation
When two gsd sessions run concurrently, the second session's
initResources() overwrites ~/.gsd/agent/ templates on disk. The first
session then reads a newer template that expects variables its in-memory
code doesn't know about, causing 'template declares {{X}} but no value
was provided' crashes that hang auto-mode indefinitely.
Fix: cache each template on first read. A running session uses the
template versions from when it first loaded them, immune to later
disk overwrites by other sessions.
2026-03-14 18:47:03 +01:00
|
|
|
// when dispatching workflow prompts. Prefers dist/resources/ (stable, set at build time)
|
|
|
|
|
// over src/resources/ (live working tree) — see resource-loader.ts for rationale.
|
2026-04-21 01:39:18 +02:00
|
|
|
// Guard: the build sometimes copies dist/resources/extensions/ without the root-level
|
|
|
|
|
// markdown files, leaving existsSync(distRes)=true but SF-WORKFLOW.md missing. Fall
|
|
|
|
|
// back to src/resources/ when the file itself isn't present in dist.
|
2026-04-29 12:42:31 +02:00
|
|
|
const distRes = join(sfRootDir, "dist", "resources");
|
|
|
|
|
const srcRes = join(sfRootDir, "src", "resources");
|
|
|
|
|
const resourcesDir = existsSync(join(distRes, "SF-WORKFLOW.md"))
|
|
|
|
|
? distRes
|
|
|
|
|
: srcRes;
|
|
|
|
|
process.env.SF_WORKFLOW_PATH = join(resourcesDir, "SF-WORKFLOW.md");
|
2026-03-10 22:28:37 -06:00
|
|
|
|
2026-04-15 14:54:20 +02:00
|
|
|
// SF_BUNDLED_EXTENSION_PATHS — dynamically discovered bundled extension entry points.
|
2026-03-17 17:14:04 -06:00
|
|
|
// Uses the shared discoverExtensionEntryPaths() to scan the bundled resources
|
2026-04-15 15:37:12 +02:00
|
|
|
// directory, then remaps discovered paths to agentDir (~/.sf/agent/extensions/)
|
2026-03-17 17:14:04 -06:00
|
|
|
// where initResources() will sync them.
|
2026-04-29 12:42:31 +02:00
|
|
|
const bundledExtDir = join(resourcesDir, "extensions");
|
|
|
|
|
const agentExtDir = join(agentDir, "extensions");
|
|
|
|
|
const registry = loadRegistry();
|
2026-03-18 14:12:19 -06:00
|
|
|
const discoveredExtensionPaths = discoverExtensionEntryPaths(bundledExtDir)
|
2026-04-29 12:42:31 +02:00
|
|
|
.map((entryPath) => join(agentExtDir, relative(bundledExtDir, entryPath)))
|
|
|
|
|
.filter((entryPath) => {
|
|
|
|
|
const manifest = readManifestFromEntryPath(entryPath);
|
|
|
|
|
if (!manifest) return true; // no manifest = always load
|
|
|
|
|
return isExtensionEnabled(registry, manifest.id);
|
|
|
|
|
});
|
2026-03-14 20:06:08 +01:00
|
|
|
|
2026-04-29 12:42:31 +02:00
|
|
|
process.env.SF_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths(
|
|
|
|
|
discoveredExtensionPaths,
|
|
|
|
|
);
|
2026-03-10 22:28:37 -06:00
|
|
|
|
2026-03-13 09:33:41 -06:00
|
|
|
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars for all outbound requests.
|
2026-04-15 14:54:20 +02:00
|
|
|
// pi-coding-agent's cli.ts sets this, but SF bypasses that entry point — so we
|
2026-03-13 09:33:41 -06:00
|
|
|
// must set it here before any SDK clients are created.
|
2026-03-15 14:33:43 -05:00
|
|
|
// Lazy-load undici (~200ms) only when proxy env vars are actually set.
|
2026-04-29 12:42:31 +02:00
|
|
|
if (
|
|
|
|
|
process.env.HTTP_PROXY ||
|
|
|
|
|
process.env.HTTPS_PROXY ||
|
|
|
|
|
process.env.http_proxy ||
|
|
|
|
|
process.env.https_proxy
|
|
|
|
|
) {
|
|
|
|
|
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import("undici");
|
|
|
|
|
setGlobalDispatcher(new EnvHttpProxyAgent());
|
2026-03-15 14:33:43 -05:00
|
|
|
}
|
2026-03-13 09:33:41 -06:00
|
|
|
|
2026-03-17 10:35:57 -05:00
|
|
|
// Ensure workspace packages are linked (or copied on Windows) before importing
|
2026-04-15 22:56:33 +02:00
|
|
|
// cli.js (which imports @singularity-forge/*).
|
2026-03-14 11:38:33 -06:00
|
|
|
// npm postinstall handles this normally, but npx --ignore-scripts skips postinstall.
|
2026-03-17 10:35:57 -05:00
|
|
|
// On Windows without Developer Mode or admin rights, symlinkSync will throw even for
|
|
|
|
|
// 'junction' type — so we fall back to cpSync (a full directory copy) which works
|
|
|
|
|
// everywhere without elevated permissions.
|
2026-04-29 12:42:31 +02:00
|
|
|
const sfScopeDir = join(sfNodeModules, "@singularity-forge");
|
|
|
|
|
const packagesDir = join(sfRootDir, "packages");
|
|
|
|
|
const wsPackages = [
|
|
|
|
|
"native",
|
|
|
|
|
"pi-agent-core",
|
|
|
|
|
"pi-ai",
|
|
|
|
|
"pi-coding-agent",
|
|
|
|
|
"pi-tui",
|
|
|
|
|
];
|
2026-03-14 11:38:33 -06:00
|
|
|
try {
|
2026-04-29 12:42:31 +02:00
|
|
|
if (!existsSync(sfScopeDir)) mkdirSync(sfScopeDir, { recursive: true });
|
|
|
|
|
for (const pkg of wsPackages) {
|
|
|
|
|
const target = join(sfScopeDir, pkg);
|
|
|
|
|
const source = join(packagesDir, pkg);
|
|
|
|
|
if (!existsSync(source) || existsSync(target)) continue;
|
|
|
|
|
try {
|
|
|
|
|
symlinkSync(source, target, "junction");
|
|
|
|
|
} catch {
|
|
|
|
|
// Symlink failed (common on Windows without Developer Mode / admin).
|
|
|
|
|
// Fall back to a directory copy — slower on first run but universally works.
|
|
|
|
|
try {
|
|
|
|
|
cpSync(source, target, { recursive: true });
|
|
|
|
|
} catch {
|
|
|
|
|
/* non-fatal */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
/* non-fatal */
|
|
|
|
|
}
|
2026-03-14 11:38:33 -06:00
|
|
|
|
2026-03-17 10:35:57 -05:00
|
|
|
// Validate critical workspace packages are resolvable. If still missing after the
|
|
|
|
|
// symlink+copy attempts, emit a clear diagnostic instead of a cryptic
|
|
|
|
|
// ERR_MODULE_NOT_FOUND from deep inside cli.js.
|
2026-04-29 12:42:31 +02:00
|
|
|
const criticalPackages = ["pi-coding-agent"];
|
|
|
|
|
const missingPackages = criticalPackages.filter(
|
|
|
|
|
(pkg) => !existsSync(join(sfScopeDir, pkg)),
|
|
|
|
|
);
|
2026-03-17 10:35:57 -05:00
|
|
|
if (missingPackages.length > 0) {
|
2026-04-29 12:42:31 +02:00
|
|
|
const missing = missingPackages
|
|
|
|
|
.map((p) => `@singularity-forge/${p}`)
|
|
|
|
|
.join(", ");
|
|
|
|
|
process.stderr.write(
|
|
|
|
|
`\nError: SF installation is broken — missing packages: ${missing}\n\n` +
|
|
|
|
|
`This is usually caused by one of:\n` +
|
|
|
|
|
` • An outdated version installed from npm (run: npm install -g singularity-foundry@latest)\n` +
|
|
|
|
|
` • The packages/ directory was excluded from the installed tarball\n` +
|
|
|
|
|
` • A filesystem error prevented linking or copying the workspace packages\n\n` +
|
|
|
|
|
`Fix it by reinstalling:\n\n` +
|
|
|
|
|
` npm install -g singularity-foundry@latest\n\n` +
|
|
|
|
|
`If the issue persists, please open an issue at:\n` +
|
|
|
|
|
` https://github.com/singularity-ng/singularity-foundry/issues\n`,
|
|
|
|
|
);
|
|
|
|
|
process.exit(1);
|
2026-03-17 10:35:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-10 22:28:37 -06:00
|
|
|
// Dynamic import defers ESM evaluation — config.js will see PI_PACKAGE_DIR above
|
2026-04-29 12:42:31 +02:00
|
|
|
await import("./cli.js");
|