diff --git a/README.md b/README.md index c16631c43..863b76d40 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ One command. Walk away. Come back to a built project with clean git history.
npm install -g singularity-forge@latest
-> SF now provisions a managed [RTK](https://github.com/rtk-ai/rtk) binary on supported macOS, Linux, and Windows installs to compress shell-command output in `bash`, `async_bash`, `bg_shell`, and verification flows. SF forces `RTK_TELEMETRY_DISABLED=1` for all managed invocations. Set `SF_RTK_DISABLED=1` to disable the integration.
+> **Supported platforms: Linux x86-64 / Linux arm64.** macOS support removed 2026-05-17 per operator direction.
+
+> SF now provisions a managed [RTK](https://github.com/rtk-ai/rtk) binary on supported Linux and Windows installs to compress shell-command output in `bash`, `async_bash`, `bg_shell`, and verification flows. SF forces `RTK_TELEMETRY_DISABLED=1` for all managed invocations. Set `SF_RTK_DISABLED=1` to disable the integration.
> **Node runtime:** SF targets Node.js 26.1+. Use the repo `.mise.toml`, `.node-version`, or `.nvmrc` pins when developing from source.
@@ -416,14 +418,14 @@ On first run, SF launches a branded setup wizard that walks you through LLM prov
| `/sf logs` | Browse activity, debug, and metrics logs |
| `/sf export --html` | Generate HTML report for current or completed milestone |
| `/worktree` (`/wt`) | Git worktree lifecycle — create, switch, merge, remove |
-| `/voice` | Toggle real-time speech-to-text (macOS, Linux) |
+| `/voice` | Toggle real-time speech-to-text (Linux) |
| `/exit` | Graceful shutdown — saves session state before exiting |
| `/kill` | Kill SF process immediately |
| `/clear` | Start a new session (alias for `/new`) |
| `Ctrl+Alt+G` | Toggle dashboard overlay |
| `Ctrl+Alt+V` | Toggle voice transcription |
| `Ctrl+Alt+B` | Show background shell processes |
-| `Alt+V` | Paste clipboard image (macOS) |
+| `Alt+V` | Paste clipboard image |
| `sf config` | Re-run the setup wizard (LLM provider + tool keys) |
| `sf update` | Update SF to the latest version |
| `sf headless [cmd]` | Machine surface for `/sf` commands (CI, cron, scripts) |
@@ -610,9 +612,8 @@ SF ships with 24 extensions, all loaded automatically:
| **Async Jobs** | Background bash commands with job tracking and cancellation |
| **Subagent** | Delegated tasks with isolated context windows |
| **GitHub** | Full-suite GitHub issues and PR management via `/gh` command |
-| **Mac Tools** | macOS native app automation via Accessibility APIs |
| **MCP Client** | Client-side connections to external MCP tool servers via @modelcontextprotocol/sdk; SF does not expose its workflow as MCP |
-| **Voice** | Real-time speech-to-text transcription (macOS, Linux — Ubuntu 22.04+) |
+| **Voice** | Real-time speech-to-text transcription (Linux — Ubuntu 22.04+) |
| **Slash Commands** | Custom command creation |
| **Ask User Questions** | Structured user input with single/multi-select |
| **Secure Env Collect** | Masked secret collection without manual .env editing |
diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts
index 72b16bac3..5aaba7937 100644
--- a/packages/ai/src/providers/google-gemini-cli.ts
+++ b/packages/ai/src/providers/google-gemini-cli.ts
@@ -6,13 +6,11 @@
* Request retry/fallback stays in the caller so SF can move to the next model.
*/
-import { retryWithBackoff } from "@google/gemini-cli-core";
import type {
Content,
GenerateContentParameters,
ThinkingConfig,
} from "@google/genai";
-import { createGeminiCliContentGenerator } from "@singularity-forge/google-gemini-cli-provider";
import { resolveWireModelId } from "../model-identity.js";
import { calculateCost } from "../models.js";
import type {
@@ -189,6 +187,11 @@ export const streamGoogleGeminiCli: StreamFunction<
if (nextReq !== undefined) {
req = nextReq as GenerateContentParameters;
}
+ const [{ retryWithBackoff }, { createGeminiCliContentGenerator }] =
+ await Promise.all([
+ import("@google/gemini-cli-core"),
+ import("@singularity-forge/google-gemini-cli-provider"),
+ ]);
// cli-core handles auth + project discovery through the helper package.
const server = await createGeminiCliContentGenerator({
modelId: req.model,
diff --git a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts
index 1d3df8abe..cbf40817d 100644
--- a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts
+++ b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts
@@ -13,15 +13,11 @@ import type {
} from "../../../core/keybindings.js";
import { theme } from "../theme/theme.js";
-const isMac = process.platform === "darwin";
-
/**
- * Convert a key identifier to a platform-appropriate display string.
- * On macOS, "alt+" is shown as "⌥" (Option key symbol).
+ * Convert a key identifier to a display string.
*/
export function formatKeyForDisplay(key: string): string {
- if (!isMac) return key;
- return key.replace(/\balt\+/gi, "⌥");
+ return key;
}
/**
diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts
index bb3c90579..9a36cb088 100644
--- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts
+++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts
@@ -167,10 +167,7 @@ export class LoginDialogComponent extends Container implements Focusable {
const urlLink = `\x1b]8;;${url}\x07${theme.fg("accent", displayUrl)}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(urlLink, 1, 0));
- const clickHint =
- process.platform === "darwin"
- ? "Cmd+click to open"
- : "Ctrl+click to open";
+ const clickHint = "Ctrl+click to open";
this.contentContainer.addChild(new Text(theme.fg("dim", clickHint), 1, 0));
if (fullUrlLines.length > 0) {
@@ -198,8 +195,7 @@ export class LoginDialogComponent extends Container implements Focusable {
() => {},
);
} else {
- const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
- execFile(openCmd, [url], () => {});
+ execFile("xdg-open", [url], () => {});
}
this.tui.requestRender();
diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts
index 27fd4e903..890570b0a 100644
--- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts
@@ -208,7 +208,7 @@ export class ScopedModelsSelectorComponent
"^A all",
"^X clear",
"^P provider",
- `${process.platform === "darwin" ? "⌥↑↓" : "Alt+↑↓"} reorder`,
+ "Alt+↑↓ reorder",
"^S save",
countText,
];
diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts
index b265c5228..0e50846c0 100644
--- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts
@@ -290,7 +290,7 @@ export class SettingsSelectorComponent extends Container {
{
id: "follow-up-mode",
label: "Follow-up mode",
- description: `${process.platform === "darwin" ? "⌥Enter" : "Alt+Enter"} queues follow-up messages until agent stops. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.`,
+ description: `Alt+Enter queues follow-up messages until agent stops. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.`,
currentValue: config.followUpMode,
values: ["one-at-a-time", "all"],
},
diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts
index 0590fcc0e..b41fe3c91 100644
--- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts
@@ -1359,7 +1359,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
new TruncatedText(
theme.fg(
"muted",
- ` ↑/↓: move. ←/→: page. ^←/^→ or ${process.platform === "darwin" ? "⌥←/⌥→" : "Alt+←/Alt+→"}: fold/branch. Shift+L: label. `,
+ ` ↑/↓: move. ←/→: page. ^←/^→ or Alt+←/Alt+→: fold/branch. Shift+L: label. `,
) + theme.fg("muted", "^D/^T/^U/^L/^A: filters (^O/⇧^O cycle)"),
0,
0,
diff --git a/src/onboarding.ts b/src/onboarding.ts
index bcd0d5408..5f0d60647 100644
--- a/src/onboarding.ts
+++ b/src/onboarding.ts
@@ -179,8 +179,7 @@ function openBrowser(url: string): void {
() => {},
);
} else {
- const cmd = process.platform === "darwin" ? "open" : "xdg-open";
- execFile(cmd, [url], () => {});
+ execFile("xdg-open", [url], () => {});
}
}
diff --git a/src/resources/extensions/sf/detectors/crash-loop-classifier.js b/src/resources/extensions/sf/detectors/crash-loop-classifier.js
index a2d012248..79a5521f4 100644
--- a/src/resources/extensions/sf/detectors/crash-loop-classifier.js
+++ b/src/resources/extensions/sf/detectors/crash-loop-classifier.js
@@ -10,6 +10,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { compareToLastGreen, getLastGreenEntry } from "../last-green.js";
+import { maybeRollbackCrashLoop } from "../safety/autonomous-rollback.js";
export const CRASH_LOOP_WINDOW_MS = 90_000;
export const CRASH_LOOP_THRESHOLD = 3;
@@ -105,11 +106,17 @@ export const crashLoopGate = {
ctx.options,
);
if (result.stuck) {
+ const rollback =
+ ctx.autoRollback === true || ctx.options?.autoRollback === true
+ ? maybeRollbackCrashLoop(ctx.basePath ?? ctx.cwd, result, ctx.options)
+ : null;
return {
outcome: "fail",
failureClass: "verification",
rationale: result.reason,
- findings: result.signature,
+ findings: rollback
+ ? { ...result.signature, rollback }
+ : result.signature,
};
}
return {
diff --git a/src/resources/extensions/sf/preferences-types.js b/src/resources/extensions/sf/preferences-types.js
index 36d577d40..13a02c216 100644
--- a/src/resources/extensions/sf/preferences-types.js
+++ b/src/resources/extensions/sf/preferences-types.js
@@ -135,6 +135,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([
"workspace",
"subscription",
"allow_flat_rate_providers",
+ "deploy",
"planning_depth",
"min_request_interval_ms",
"context_compact_at",
diff --git a/src/resources/extensions/sf/safety/autonomous-rollback.js b/src/resources/extensions/sf/safety/autonomous-rollback.js
new file mode 100644
index 000000000..ce52ecf09
--- /dev/null
+++ b/src/resources/extensions/sf/safety/autonomous-rollback.js
@@ -0,0 +1,180 @@
+/**
+ * autonomous-rollback.js — revert autonomous commits after regression quarantine.
+ *
+ * Purpose: give M048/S04 a bounded rollback path for crash-loop regressions
+ * without letting generic detectors mutate git state by accident.
+ *
+ * Consumer: crash-loop quarantine handling and future daemon supervisor policy.
+ */
+import { execFileSync } from "node:child_process";
+
+import { recordSelfFeedback } from "../self-feedback.js";
+
+export const AUTONOMOUS_COMMIT_PATTERNS = [
+ /sf-snapshot-/i,
+ /autonomous-/i,
+ /SF-Task:/i,
+ /singularity-forge autonomous/i,
+];
+
+/**
+ * Find the newest commit that looks autonomous by subject, body, or author.
+ *
+ * Purpose: keep rollback scoped to SF-authored changes rather than reverting
+ * arbitrary operator commits after a detector fires.
+ *
+ * Consumer: revertAutonomousRegression().
+ */
+export function findLastAutonomousCommit(basePath, options = {}) {
+ const maxCount = options.maxCount ?? 30;
+ const runGit = options.runGit ?? defaultRunGit;
+ const output = runGit(basePath, [
+ "log",
+ `-${maxCount}`,
+ "--format=%H%x00%an%x00%ae%x00%s%x00%b%x1e",
+ ]);
+ return (
+ parseGitLog(output).find((commit) => isAutonomousCommit(commit)) ?? null
+ );
+}
+
+/**
+ * Revert the newest autonomous commit and record rollback self-feedback.
+ *
+ * Purpose: close the M048/S04 loop from crash-loop quarantine to actionable
+ * rollback evidence while leaving non-autonomous commits untouched.
+ *
+ * Consumer: daemon/server supervisor when crashLoopGate reports quarantine.
+ */
+export function revertAutonomousRegression(
+ basePath,
+ signature = {},
+ options = {},
+) {
+ const runGit = options.runGit ?? defaultRunGit;
+ const record =
+ options.recordSelfFeedback ??
+ ((entry) => recordSelfFeedback(entry, basePath));
+ const commit =
+ options.commit ??
+ findLastAutonomousCommit(basePath, { ...options, runGit });
+ if (!commit) {
+ const result = {
+ ok: false,
+ reverted: false,
+ reason: "no-autonomous-commit",
+ signature,
+ };
+ recordRollbackFeedback(record, result, commit);
+ return result;
+ }
+
+ try {
+ runGit(basePath, ["revert", "--no-edit", commit.sha]);
+ const result = {
+ ok: true,
+ reverted: true,
+ reason: "reverted-autonomous-commit",
+ commit,
+ signature,
+ };
+ recordRollbackFeedback(record, result, commit);
+ return result;
+ } catch (error) {
+ const result = {
+ ok: false,
+ reverted: false,
+ reason: "git-revert-failed",
+ error: error instanceof Error ? error.message : String(error),
+ commit,
+ signature,
+ };
+ recordRollbackFeedback(record, result, commit);
+ return result;
+ }
+}
+
+/**
+ * Apply rollback when a crash-loop detector result is quarantined.
+ *
+ * Purpose: keep gate/run-control callers on one small API: pass the detector
+ * result, get either no-op or rollback outcome.
+ *
+ * Consumer: crashLoopGate and future embedded supervisor backoff policy.
+ */
+export function maybeRollbackCrashLoop(basePath, detectorResult, options = {}) {
+ if (!detectorResult?.stuck) {
+ return { ok: true, reverted: false, reason: "not-quarantined" };
+ }
+ return revertAutonomousRegression(
+ basePath,
+ detectorResult.signature ?? {},
+ options,
+ );
+}
+
+function defaultRunGit(basePath, args) {
+ return execFileSync("git", args, {
+ cwd: basePath,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ timeout: 30_000,
+ });
+}
+
+function parseGitLog(output) {
+ return String(output ?? "")
+ .split("\x1e")
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+ .map((entry) => {
+ const [sha, authorName, authorEmail, subject, ...bodyParts] =
+ entry.split("\x00");
+ return {
+ sha,
+ authorName,
+ authorEmail,
+ subject,
+ body: bodyParts.join("\x00"),
+ };
+ })
+ .filter((commit) => /^[0-9a-f]{7,40}$/i.test(commit.sha ?? ""));
+}
+
+function isAutonomousCommit(commit) {
+ const haystack = [
+ commit.authorName,
+ commit.authorEmail,
+ commit.subject,
+ commit.body,
+ ]
+ .filter(Boolean)
+ .join("\n");
+ return AUTONOMOUS_COMMIT_PATTERNS.some((pattern) => pattern.test(haystack));
+}
+
+function recordRollbackFeedback(record, result, commit) {
+ record({
+ kind: "runaway-loop:crash-loop-rollback",
+ severity: result.ok ? "medium" : "high",
+ summary: result.ok
+ ? `Reverted autonomous regression commit ${commit?.sha ?? "unknown"}.`
+ : `Crash-loop rollback did not revert a commit: ${result.reason}.`,
+ evidence: {
+ reason: result.reason,
+ commit: commit
+ ? {
+ sha: commit.sha,
+ subject: commit.subject,
+ authorName: commit.authorName,
+ authorEmail: commit.authorEmail,
+ }
+ : null,
+ signature: result.signature,
+ error: result.error ?? null,
+ },
+ suggestedFix: result.ok
+ ? "Verify the loop resumes on the reverted parent and keep the crash signature for regression tests."
+ : "Inspect the crash-loop signature and revert manually if the failed commit is not machine-identifiable.",
+ });
+}
diff --git a/src/resources/extensions/sf/self-feedback-drain.js b/src/resources/extensions/sf/self-feedback-drain.js
index 77160d113..5785b720a 100644
--- a/src/resources/extensions/sf/self-feedback-drain.js
+++ b/src/resources/extensions/sf/self-feedback-drain.js
@@ -41,6 +41,7 @@ const CREDIBLE_RESOLUTION_KINDS = new Set([
"agent-fix",
"human-clear",
"promoted-to-requirement",
+ "adversarial-finding",
]);
/**
diff --git a/src/resources/extensions/sf/tests/autonomous-rollback.test.mjs b/src/resources/extensions/sf/tests/autonomous-rollback.test.mjs
new file mode 100644
index 000000000..e445e5520
--- /dev/null
+++ b/src/resources/extensions/sf/tests/autonomous-rollback.test.mjs
@@ -0,0 +1,149 @@
+/**
+ * autonomous-rollback.test.mjs — M048/S04 rollback controller contracts.
+ *
+ * Purpose: prove crash-loop rollback only targets autonomous-looking commits
+ * and records operator-visible self-feedback for success and failure.
+ */
+import assert from "node:assert/strict";
+import { test } from "vitest";
+
+import {
+ findLastAutonomousCommit,
+ maybeRollbackCrashLoop,
+ revertAutonomousRegression,
+} from "../safety/autonomous-rollback.js";
+
+function gitLog(commits) {
+ return commits
+ .map((commit) =>
+ [
+ commit.sha,
+ commit.authorName ?? "Human",
+ commit.authorEmail ?? "human@example.com",
+ commit.subject ?? "manual change",
+ commit.body ?? "",
+ ].join("\x00"),
+ )
+ .join("\x1e");
+}
+
+test("findLastAutonomousCommit_when_subject_has_sf_snapshot_returns_newest_match", () => {
+ const output = gitLog([
+ { sha: "aaaaaaaa", subject: "manual edit" },
+ { sha: "bbbbbbbb", subject: "sf-snapshot-M048 detector patch" },
+ ]);
+ const commit = findLastAutonomousCommit("/repo", {
+ runGit: () => output,
+ });
+
+ assert.equal(commit.sha, "bbbbbbbb");
+ assert.equal(commit.subject, "sf-snapshot-M048 detector patch");
+});
+
+test("findLastAutonomousCommit_when_body_has_sf_task_returns_match", () => {
+ const output = gitLog([
+ {
+ sha: "cccccccc",
+ subject: "feat: add detector",
+ body: "SF-Task: M048/S04/T01",
+ },
+ ]);
+ const commit = findLastAutonomousCommit("/repo", {
+ runGit: () => output,
+ });
+
+ assert.equal(commit.sha, "cccccccc");
+});
+
+test("findLastAutonomousCommit_when_no_autonomous_marker_returns_null", () => {
+ const output = gitLog([{ sha: "dddddddd", subject: "human commit" }]);
+ const commit = findLastAutonomousCommit("/repo", {
+ runGit: () => output,
+ });
+
+ assert.equal(commit, null);
+});
+
+test("revertAutonomousRegression_when_commit_found_runs_git_revert_and_records_feedback", () => {
+ const calls = [];
+ const feedback = [];
+ const result = revertAutonomousRegression(
+ "/repo",
+ { sourceHash: "source-a" },
+ {
+ runGit: (_cwd, args) => {
+ calls.push(args);
+ if (args[0] === "log") {
+ return gitLog([
+ { sha: "eeeeeeee", subject: "sf-snapshot-M048 bad change" },
+ ]);
+ }
+ return "";
+ },
+ recordSelfFeedback: (entry) => feedback.push(entry),
+ },
+ );
+
+ assert.equal(result.ok, true);
+ assert.equal(result.reverted, true);
+ assert.deepEqual(calls.at(-1), ["revert", "--no-edit", "eeeeeeee"]);
+ assert.equal(feedback.length, 1);
+ assert.equal(feedback[0].kind, "runaway-loop:crash-loop-rollback");
+ assert.equal(feedback[0].severity, "medium");
+});
+
+test("revertAutonomousRegression_when_no_commit_records_blocking_feedback", () => {
+ const feedback = [];
+ const result = revertAutonomousRegression(
+ "/repo",
+ { sourceHash: "source-a" },
+ {
+ runGit: () => gitLog([{ sha: "ffffffff", subject: "manual commit" }]),
+ recordSelfFeedback: (entry) => feedback.push(entry),
+ },
+ );
+
+ assert.equal(result.ok, false);
+ assert.equal(result.reason, "no-autonomous-commit");
+ assert.equal(feedback[0].severity, "high");
+});
+
+test("revertAutonomousRegression_when_git_revert_fails_records_failure", () => {
+ const feedback = [];
+ const result = revertAutonomousRegression(
+ "/repo",
+ { sourceHash: "source-a" },
+ {
+ runGit: (_cwd, args) => {
+ if (args[0] === "log") {
+ return gitLog([
+ { sha: "11111111", subject: "autonomous-M048 bad change" },
+ ]);
+ }
+ throw new Error("merge conflict");
+ },
+ recordSelfFeedback: (entry) => feedback.push(entry),
+ },
+ );
+
+ assert.equal(result.ok, false);
+ assert.equal(result.reason, "git-revert-failed");
+ assert.match(result.error, /merge conflict/);
+ assert.equal(feedback[0].severity, "high");
+});
+
+test("maybeRollbackCrashLoop_when_not_stuck_is_noop", () => {
+ const result = maybeRollbackCrashLoop(
+ "/repo",
+ { stuck: false, signature: {} },
+ {
+ runGit: () => {
+ throw new Error("should not run");
+ },
+ },
+ );
+
+ assert.equal(result.ok, true);
+ assert.equal(result.reverted, false);
+ assert.equal(result.reason, "not-quarantined");
+});
diff --git a/src/resources/extensions/sf/tests/self-feedback-drain.test.mjs b/src/resources/extensions/sf/tests/self-feedback-drain.test.mjs
index 46c8b62b0..9af57f07c 100644
--- a/src/resources/extensions/sf/tests/self-feedback-drain.test.mjs
+++ b/src/resources/extensions/sf/tests/self-feedback-drain.test.mjs
@@ -151,6 +151,13 @@ describe("selectInlineFixCandidates", () => {
requirementId: "R1",
},
}),
+ entry({
+ id: "adversarial-reviewed",
+ kind: "adversarial-finding",
+ resolvedAt: "2026-05-10T00:00:00Z",
+ resolvedEvidence: { kind: "adversarial-finding" },
+ resolvedReason: "challenge review accepted as the closing evidence",
+ }),
]);
expect(selectInlineFixCandidates(dir)).toEqual([]);
});
diff --git a/src/resources/extensions/sf/ui/usage-bar.js b/src/resources/extensions/sf/ui/usage-bar.js
index 29292ff3a..5d8596642 100644
--- a/src/resources/extensions/sf/ui/usage-bar.js
+++ b/src/resources/extensions/sf/ui/usage-bar.js
@@ -11,7 +11,6 @@ import { execSync, spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
-import { snapshotGeminiCliAccount } from "@singularity-forge/google-gemini-cli-provider";
import { visibleWidth } from "@singularity-forge/tui";
import { sfHome } from "../sf-home.js";
@@ -209,6 +208,9 @@ async function fetchGeminiUsage(_modelRegistry) {
};
}
try {
+ const { snapshotGeminiCliAccount } = await import(
+ "@singularity-forge/google-gemini-cli-provider"
+ );
const snapshot = await snapshotGeminiCliAccount();
if (!snapshot) {
return {
diff --git a/src/resources/extensions/shared/notify.js b/src/resources/extensions/shared/notify.js
index cc54ab9d9..988e10908 100644
--- a/src/resources/extensions/shared/notify.js
+++ b/src/resources/extensions/shared/notify.js
@@ -1,13 +1,10 @@
/**
* Shared notification utilities for bundled extensions
*
- * Provides cross-platform beep, speech, and bring-to-front helpers.
- * macOS features are fully supported; Linux/others gracefully degrade.
+ * Provides beep and notification helpers for Linux.
+ * macOS support removed 2026-05-17 per operator direction.
*/
import * as child_process from "node:child_process";
-import * as fsPromises from "node:fs/promises";
-import * as os from "node:os";
-import * as path from "node:path";
import { promisify } from "node:util";
const execAsync = promisify(child_process.exec);
@@ -40,123 +37,35 @@ export const SAY_MESSAGES = [
"All done in {dirname}",
"{session dir} needs your attention",
];
-const TERMINAL_BUNDLE_IDS = {
- "com.googlecode.iterm2": "iTerm2",
- "iTerm.app": "iTerm2",
-};
// ─────────────────────────────────────────────────────────────────────────────
-// Platform Detection
+// Platform Detection (stubs — Linux only)
// ─────────────────────────────────────────────────────────────────────────────
export function isMacOS() {
- return process.platform === "darwin";
+ return false;
}
-let hasSayCommand = false;
export async function checkSayAvailable() {
- if (!isMacOS()) {
- hasSayCommand = false;
- return false;
- }
- try {
- await execAsync("which say");
- hasSayCommand = true;
- return true;
- } catch {
- hasSayCommand = false;
- return false;
- }
+ return false;
}
export function isSayAvailable() {
- return hasSayCommand;
+ return false;
}
// ─────────────────────────────────────────────────────────────────────────────
-// Terminal Detection
+// Terminal Detection (Linux: always returns empty info)
// ─────────────────────────────────────────────────────────────────────────────
export async function detectTerminalInfo() {
- const info = {};
- if (!isMacOS()) return info;
- try {
- info.terminalPid = process.ppid;
- info.terminalApp = process.env.TERM_PROGRAM;
- if (!info.terminalTTY) {
- try {
- const { stdout } = await execAsync(`ps -p ${process.ppid} -o tty=`);
- const tty = stdout.trim();
- if (tty && tty !== "??") {
- info.terminalTTY = tty.startsWith("/dev/") ? tty : "/dev/" + tty;
- }
- } catch {}
- }
- if (!info.terminalTTY && info.terminalPid) {
- try {
- const { stdout } = await execAsync(
- `lsof -p ${info.terminalPid} 2>/dev/null | grep -m1 "/dev/ttys" | awk '{print $9}'`,
- );
- const tty = stdout.trim();
- if (tty?.startsWith("/dev/")) info.terminalTTY = tty;
- } catch {}
- }
- if (!info.terminalApp) {
- try {
- const { stdout } = await execAsync(
- `lsappinfo info -only bundleID ${info.terminalPid}`,
- );
- const match = stdout.match(/"CFBundleIdentifier"="([^"]+)"/);
- if (match) info.terminalApp = match[1];
- } catch {
- info.terminalApp = "com.googlecode.iterm2";
- }
- }
- } catch {}
- return info;
+ return {};
}
-export async function isTerminalInBackground(info) {
- if (!isMacOS()) return false;
- try {
- const { stdout } = await execAsync(
- "lsappinfo front | awk '{print $1}' | xargs -I {} lsappinfo info -only bundleID {}",
- );
- const match = stdout.match(/"CFBundleIdentifier"="([^"]+)"/);
- if (!match) return false;
- const frontBundleId = match[1];
- if (info.terminalApp && !frontBundleId.includes(info.terminalApp))
- return true;
- const knownTerminals = Object.keys(TERMINAL_BUNDLE_IDS).filter((k) =>
- k.includes("."),
- );
- return !knownTerminals.some((id) => frontBundleId.includes(id));
- } catch {
- return false;
- }
+export async function isTerminalInBackground(_info) {
+ return false;
}
// ─────────────────────────────────────────────────────────────────────────────
-// terminal-notifier
+// terminal-notifier (macOS-only — always unavailable)
// ─────────────────────────────────────────────────────────────────────────────
-let terminalNotifierAvailable = false;
-let terminalNotifierChecked = false;
-const TERMINAL_NOTIFIER_PATHS = [
- "/Applications/terminal-notifier.app/Contents/MacOS/terminal-notifier",
- "/usr/local/bin/terminal-notifier",
- "/opt/homebrew/bin/terminal-notifier",
-];
export async function checkTerminalNotifierAvailable() {
- if (!isMacOS() || terminalNotifierChecked) return terminalNotifierAvailable;
- try {
- await execAsync("which terminal-notifier");
- for (const p of TERMINAL_NOTIFIER_PATHS) {
- try {
- await execAsync(`test -f "${p}"`);
- terminalNotifierAvailable = true;
- break;
- } catch {}
- }
- } catch {
- terminalNotifierAvailable = false;
- }
- terminalNotifierChecked = true;
- return terminalNotifierAvailable;
+ return false;
}
export function isTerminalNotifierAvailable() {
- return terminalNotifierAvailable;
+ return false;
}
// ─────────────────────────────────────────────────────────────────────────────
// Message Helpers
@@ -177,15 +86,8 @@ export function replaceMessageTemplates(message) {
// ─────────────────────────────────────────────────────────────────────────────
// Notification Actions
// ─────────────────────────────────────────────────────────────────────────────
-export function playBeep(soundName = "Tink") {
- if (isMacOS()) {
- child_process
- .spawn("afplay", [`/System/Library/Sounds/${soundName}.aiff`], {
- detached: true,
- stdio: "ignore",
- })
- .unref();
- } else if (process.platform === "linux") {
+export function playBeep(_soundName = "Tink") {
+ if (process.platform === "linux") {
try {
child_process
.spawn("paplay", ["/usr/share/sounds/freedesktop/stereo/bell.oga"], {
@@ -201,92 +103,25 @@ export function playBeep(soundName = "Tink") {
}
}
export function displayOSXNotification(message, soundName, _terminalInfo) {
- if (!isMacOS()) {
- if (soundName) playBeep(soundName);
- return;
- }
- const finalMessage = replaceMessageTemplates(message);
- const terminalBundleId = "com.googlecode.iterm2";
- if (terminalNotifierAvailable) {
- const args = [
- "-message",
- finalMessage,
- "-title",
- "SF Notify",
- "-activate",
- terminalBundleId,
- ];
- if (soundName) args.push("-sound", soundName);
- child_process
- .spawn("terminal-notifier", args, { detached: true, stdio: "ignore" })
- .unref();
- return;
- }
- const escapedMessage = finalMessage
- .replace(/\\/g, "\\\\")
- .replace(/"/g, '\\"');
- const terminalAppName = "iTerm2";
- let script = `tell application "${terminalAppName}" to display notification "${escapedMessage}" with title "SF Notify"`;
- if (soundName) script += ` sound name "${soundName}"`;
- child_process
- .spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" })
- .unref();
+ // macOS-specific notification removed. Degrade to beep only.
+ if (soundName) playBeep(soundName);
}
-export function speakMessage(message) {
- if (!isSayAvailable()) return;
- const finalMessage = replaceMessageTemplates(message).replace(/"/g, '\\"');
- child_process
- .spawn("say", ["-v", "Daniel", finalMessage], {
- detached: true,
- stdio: "ignore",
- })
- .unref();
+export function speakMessage(_message) {
+ // macOS `say` command removed. No-op on Linux.
}
-export async function bringTerminalToFront(info) {
- if (!isMacOS()) return;
- try {
- let script;
- if (info.terminalTTY) {
- script = `tell application "iTerm2"
- repeat with w in windows
- set tabIdx to 0
- repeat with t in tabs of w
- set tabIdx to tabIdx + 1
- repeat with s in sessions of t
- if tty of s is "${info.terminalTTY}" then
- tell w to select tab tabIdx
- activate
- return
- end if
- end repeat
- end repeat
- end repeat
-end tell`;
- } else {
- script = `tell application "iTerm2" to activate`;
- }
- const tmpFile = path.join(os.tmpdir(), `sf-terminal-${Date.now()}.scpt`);
- try {
- await fsPromises.writeFile(tmpFile, script, "utf8");
- await execAsync(`osascript "${tmpFile}"`);
- } finally {
- try {
- await fsPromises.unlink(tmpFile);
- } catch {}
- }
- } catch {}
+export async function bringTerminalToFront(_info) {
+ // macOS osascript removed. No-op on Linux.
}
export async function notifyOnConfirm(config, terminalInfo, options) {
const eff = {
beep: options?.beep ?? config.beep,
beepSound: options?.beepSound ?? config.beepSound,
bringToFront: options?.bringToFront ?? config.bringToFront,
- say: isSayAvailable() ? (options?.say ?? config.say) : false,
+ say: false,
sayMessage: options?.sayMessage ?? config.sayMessage,
};
const tasks = [];
if (eff.bringToFront) tasks.push(bringTerminalToFront(terminalInfo));
if (eff.beep) playBeep(eff.beepSound);
- if (eff.say) speakMessage(eff.sayMessage);
await Promise.all(tasks);
}
diff --git a/src/resources/extensions/voice/index.js b/src/resources/extensions/voice/index.js
index 67fb48f00..c2987ac0b 100644
--- a/src/resources/extensions/voice/index.js
+++ b/src/resources/extensions/voice/index.js
@@ -1,4 +1,4 @@
-import { execFileSync, spawn } from "node:child_process";
+import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import * as readline from "node:readline";
@@ -17,34 +17,8 @@ import {
} from "./linux-ready.js";
const __extensionDir = import.meta.dirname;
-const SWIFT_SRC = path.join(__extensionDir, "speech-recognizer.swift");
-const RECOGNIZER_BIN = path.join(__extensionDir, "speech-recognizer");
const PYTHON_SCRIPT = path.join(__extensionDir, "speech-recognizer.py");
-const IS_DARWIN = process.platform === "darwin";
const IS_LINUX = process.platform === "linux";
-function ensureBinary() {
- if (fs.existsSync(RECOGNIZER_BIN)) return true;
- try {
- execFileSync(
- "swiftc",
- [
- SWIFT_SRC,
- "-o",
- RECOGNIZER_BIN,
- "-framework",
- "Speech",
- "-framework",
- "AVFoundation",
- ],
- {
- timeout: 60000,
- },
- );
- return true;
- } catch {
- return false;
- }
-}
let linuxReady = false;
function ensureLinuxReady(ctx) {
if (linuxReady) return true;
@@ -103,7 +77,7 @@ function ensureLinuxReady(ctx) {
return true;
}
export default function (pi) {
- if (!IS_DARWIN && !IS_LINUX) return;
+ if (!IS_LINUX) return;
let active = false;
let recognizerProcess = null;
let flashOn = true;
@@ -205,15 +179,7 @@ export default function (pi) {
setVoiceFooter(ctx, false);
return;
}
- if (IS_DARWIN) {
- if (!ensureBinary()) {
- ctx.ui.notify(
- "Voice: failed to compile speech recognizer (need Xcode CLI tools)",
- "error",
- );
- return;
- }
- } else if (IS_LINUX) {
+ if (IS_LINUX) {
if (!ensureLinuxReady(ctx)) {
return;
}
@@ -237,15 +203,9 @@ export default function (pi) {
}
}
function startRecognizer(onPartial, onFinal, onError, onReady) {
- if (IS_LINUX) {
- recognizerProcess = spawn(linuxPython(), [PYTHON_SCRIPT], {
- stdio: ["pipe", "pipe", "pipe"],
- });
- } else {
- recognizerProcess = spawn(RECOGNIZER_BIN, [], {
- stdio: ["pipe", "pipe", "pipe"],
- });
- }
+ recognizerProcess = spawn(linuxPython(), [PYTHON_SCRIPT], {
+ stdio: ["pipe", "pipe", "pipe"],
+ });
const rl = readline.createInterface({ input: recognizerProcess.stdout });
rl.on("line", (line) => {
if (line === "READY") {
diff --git a/src/tests/integration/web-mode-runtime-harness.ts b/src/tests/integration/web-mode-runtime-harness.ts
index ffc348c7d..0b74d6bc4 100644
--- a/src/tests/integration/web-mode-runtime-harness.ts
+++ b/src/tests/integration/web-mode-runtime-harness.ts
@@ -153,9 +153,8 @@ export function writePreseededAuthFile(tempHome: string): void {
}
function createBrowserOpenStub(binDir: string, logPath: string): void {
- const command = process.platform === "darwin" ? "open" : "xdg-open";
const script = `#!/bin/sh\nprintf '%s\n' "$1" >> "${logPath}"\nexit 0\n`;
- const scriptPath = join(binDir, command);
+ const scriptPath = join(binDir, "xdg-open");
writeFileSync(scriptPath, script, "utf-8");
chmodSync(scriptPath, 0o755);
}
diff --git a/src/tests/integration/web-voice-ivr-contract.test.ts b/src/tests/integration/web-voice-ivr-contract.test.ts
deleted file mode 100644
index 63bec99cb..000000000
--- a/src/tests/integration/web-voice-ivr-contract.test.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import assert from "node:assert/strict";
-import { afterEach, describe, test, vi } from "vitest";
-
-const voiceRoute = await import("../../../web/app/api/voice/route.ts");
-const voicePromptRoute = await import(
- "../../../web/app/api/voice/prompt/route.ts"
-);
-
-const ENV_KEYS = [
- "ELEVENLABS_API_KEY",
- "ELEVENLABS_VOICE_ID",
- "ELEVENLABS_MODEL_ID",
- "TWILIO_IVR_AI_NUMBER",
- "TWILIO_IVR_AI_NUMBERS",
- "TWILIO_IVR_ONCALL_NUMBER",
- "TWILIO_IVR_ONCALL_NUMBERS",
- "TWILIO_ONCALL_NUMBER",
- "ONCALL_NUMBER",
-] as const;
-
-const originalEnv = Object.fromEntries(
- ENV_KEYS.map((key) => [key, process.env[key]]),
-) as Partial