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>; - -function setEnv( - values: Partial>, -): void { - for (const key of ENV_KEYS) { - const value = values[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - -afterEach(() => { - setEnv(originalEnv); - vi.unstubAllGlobals(); -}); - -describe("voice IVR", () => { - test("voice_GET_when_oncall_number_configured_renders_menu", async () => { - setEnv({ - TWILIO_IVR_ONCALL_NUMBER: "+16175551212", - }); - - const response = await voiceRoute.GET( - new Request("http://localhost/api/voice"), - ); - const xml = await response.text(); - - assert.equal( - response.headers.get("content-type"), - "text/xml; charset=utf-8", - ); - assert.match(xml, / { - setEnv({ - TWILIO_IVR_ONCALL_NUMBERS: "+16175551212,+16175551213", - }); - - const response = await voiceRoute.POST( - new Request("http://localhost/api/voice", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: "Digits=1", - }), - ); - const xml = await response.text(); - - assert.match(xml, //); - assert.match(xml, /\+16175551212<\/Number>/); - assert.match(xml, /\+16175551213<\/Number>/); - }); - - test("voice_GET_when_elevenlabs_configured_uses_prompt_audio", async () => { - setEnv({ - ELEVENLABS_API_KEY: "test-api-key", - ELEVENLABS_VOICE_ID: "voice-123", - ELEVENLABS_MODEL_ID: "eleven_flash_v2_5", - TWILIO_IVR_ONCALL_NUMBER: "+16175551212", - }); - - const response = await voiceRoute.GET( - new Request("https://example.com/api/voice"), - ); - const xml = await response.text(); - - assert.match(xml, /https:\/\/example\.com\/api\/voice\/prompt\?/); - assert.match(xml, /text=Welcome\+to\+Singularity\+Forge\./); - }); - - test("voice_prompt_GET_when_configured_returns_audio", async () => { - setEnv({ - ELEVENLABS_API_KEY: "test-api-key", - ELEVENLABS_VOICE_ID: "voice-123", - ELEVENLABS_MODEL_ID: "eleven_flash_v2_5", - }); - - const fetchCalls: Array<[string, RequestInit | undefined]> = []; - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - fetchCalls.push([String(input), init]); - return new Response(new Uint8Array([1, 2, 3]), { - headers: { - "content-type": "audio/mpeg", - }, - }); - }), - ); - - const response = await voicePromptRoute.GET( - new Request( - "https://example.com/api/voice/prompt?text=Welcome%20to%20Singularity%20Forge.", - ), - ); - const body = new Uint8Array(await response.arrayBuffer()); - - assert.equal(response.status, 200); - assert.equal(response.headers.get("content-type"), "audio/mpeg"); - assert.deepEqual(Array.from(body), [1, 2, 3]); - assert.equal(fetchCalls.length, 1); - assert.match( - fetchCalls[0]![0], - /https:\/\/api\.elevenlabs\.io\/v1\/text-to-speech\/voice-123\?output_format=mp3_44100_128/, - ); - assert.equal( - (fetchCalls[0]![1]?.headers as Record)["xi-api-key"], - "test-api-key", - ); - }); -}); diff --git a/src/tests/rtk.test.ts b/src/tests/rtk.test.ts index 18af13393..467d139e0 100644 --- a/src/tests/rtk.test.ts +++ b/src/tests/rtk.test.ts @@ -44,14 +44,9 @@ afterEach(() => { }); test("resolveRtkAssetName maps supported release assets correctly", () => { - assert.equal( - resolveRtkAssetName("darwin", "arm64"), - "rtk-aarch64-apple-darwin.tar.gz", - ); - assert.equal( - resolveRtkAssetName("darwin", "x64"), - "rtk-x86_64-apple-darwin.tar.gz", - ); + // darwin removed 2026-05-17 per operator direction — no Mac support + assert.equal(resolveRtkAssetName("darwin", "arm64"), null); + assert.equal(resolveRtkAssetName("darwin", "x64"), null); assert.equal( resolveRtkAssetName("linux", "arm64"), "rtk-aarch64-unknown-linux-gnu.tar.gz", diff --git a/src/web-mode.ts b/src/web-mode.ts index cba5fc8d4..cf79681c2 100644 --- a/src/web-mode.ts +++ b/src/web-mode.ts @@ -35,8 +35,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/web/app/api/voice/prompt/route.ts b/web/app/api/voice/prompt/route.ts deleted file mode 100644 index b0544b96a..000000000 --- a/web/app/api/voice/prompt/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * ElevenLabs prompt synthesizer for the SF Twilio IVR. - * - * Purpose: keep spoken IVR prompts outside Twilio's built-in TTS so the voice - * can be swapped to ElevenLabs without changing the call-control webhook. - * - * Consumer: the Twilio IVR route requests this endpoint when ElevenLabs prompt - * synthesis is configured. - */ - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -function noStoreHeaders(): HeadersInit { - return { - "Cache-Control": "no-store", - }; -} - -function getElevenLabsConfig(): { - apiKey: string; - voiceId: string; - modelId: string; -} | null { - const env = process.env; - const apiKey = env.ELEVENLABS_API_KEY?.trim() ?? ""; - const voiceId = env.ELEVENLABS_VOICE_ID?.trim() ?? ""; - if (!apiKey || !voiceId) return null; - return { - apiKey, - modelId: env.ELEVENLABS_MODEL_ID?.trim() || "eleven_flash_v2_5", - voiceId, - }; -} - -function getPromptText(request: Request): string | null { - const url = new URL(request.url); - const text = url.searchParams.get("text")?.trim(); - return text ? text : null; -} - -/** - * Synthesize a spoken prompt with ElevenLabs and return audio bytes. - * - * Purpose: feed Twilio `` with a consistent prompt voice when the IVR is - * configured to use ElevenLabs. - * - * Consumer: Twilio IVR TwiML route as an audio asset source. - */ -export async function GET(request: Request): Promise { - const config = getElevenLabsConfig(); - const text = getPromptText(request); - - if (!config || !text) { - return new Response("ElevenLabs prompt synthesis is not configured.", { - status: 503, - headers: noStoreHeaders(), - }); - } - - const response = await fetch( - `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(config.voiceId)}?output_format=mp3_44100_128`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "xi-api-key": config.apiKey, - Accept: "audio/mpeg", - }, - body: JSON.stringify({ - text, - model_id: config.modelId, - }), - }, - ); - - if (!response.ok) { - const message = await response.text().catch(() => ""); - return new Response(message || "ElevenLabs prompt synthesis failed.", { - status: response.status, - headers: noStoreHeaders(), - }); - } - - return new Response(response.body, { - status: 200, - headers: { - "Cache-Control": "no-store", - "Content-Type": response.headers.get("content-type") || "audio/mpeg", - }, - }); -} diff --git a/web/app/api/voice/route.ts b/web/app/api/voice/route.ts deleted file mode 100644 index 4cf4dee4a..000000000 --- a/web/app/api/voice/route.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Twilio IVR webhook for customer calls into the SF voice bridge. - * - * Purpose: give the public Twilio number a deterministic menu that can route - * callers to the on-call line or to an optional AI assistant without adding a - * separate voice service. - * - * Consumer: Twilio Programmable Voice webhook hits this route on inbound calls. - */ - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -type VoiceRoutingConfig = { - onCallNumbers: string[]; - aiNumbers: string[]; -}; - -type VoicePromptConfig = { - useElevenLabs: boolean; - voiceId: string | null; - modelId: string; - apiKey: string | null; -}; - -function noStoreHeaders(): HeadersInit { - return { - "Cache-Control": "no-store", - "Content-Type": "text/xml; charset=utf-8", - }; -} - -function escapeXml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function splitPhoneList(value: string | undefined): string[] { - if (!value) return []; - return value - .split(/[\s,]+/) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function getRoutingConfig(): VoiceRoutingConfig { - const env = process.env; - const onCallNumbers = splitPhoneList( - env.TWILIO_IVR_ONCALL_NUMBERS ?? - env.TWILIO_IVR_ONCALL_NUMBER ?? - env.TWILIO_ONCALL_NUMBER ?? - env.ONCALL_NUMBER, - ); - const aiNumbers = splitPhoneList( - env.TWILIO_IVR_AI_NUMBERS ?? env.TWILIO_IVR_AI_NUMBER, - ); - return { aiNumbers, onCallNumbers }; -} - -function getPromptConfig(): VoicePromptConfig { - const env = process.env; - const apiKey = env.ELEVENLABS_API_KEY?.trim() ?? null; - const voiceId = env.ELEVENLABS_VOICE_ID?.trim() ?? null; - return { - apiKey, - modelId: env.ELEVENLABS_MODEL_ID?.trim() || "eleven_flash_v2_5", - useElevenLabs: Boolean(apiKey && voiceId), - voiceId, - }; -} - -function buildTwiml(body: string): string { - return `\n\n${body}\n\n`; -} - -function buildMenuPrompt(config: VoiceRoutingConfig): string { - const lines = [ - `Welcome to Singularity Forge.`, - `Press 1 to reach the on-call line.`, - ]; - if (config.aiNumbers.length > 0) { - lines.push(`Press 2 for the AI assistant.`); - } - lines.push(`Press 9 to repeat this menu.`); - return lines.join(" "); -} - -function buildPromptVerb(promptText: string, requestUrl: string): string { - const promptConfig = getPromptConfig(); - if (!promptConfig.useElevenLabs) { - return `${escapeXml(promptText)}`; - } - const promptUrl = new URL("/api/voice/prompt", requestUrl); - promptUrl.searchParams.set("text", promptText); - return `${escapeXml(promptUrl.toString())}`; -} - -function buildDialTargets(numbers: string[]): string { - if (numbers.length === 0) { - return `The requested destination is not configured.\n`; - } - return [ - ``, - ...numbers.map((number) => ` ${escapeXml(number)}`), - ``, - ].join("\n"); -} - -function buildMenuResponse( - requestUrl: string, - config: VoiceRoutingConfig, -): string { - return buildTwiml( - [ - ``, - ` ${buildPromptVerb(buildMenuPrompt(config), requestUrl)}`, - ``, - `Sorry, we did not get a selection.`, - `/api/voice`, - ].join("\n"), - ); -} - -function buildDigitsResponse( - digits: string | null, - requestUrl: string, - config: VoiceRoutingConfig, -): string { - if (digits === "1") { - return buildTwiml(buildDialTargets(config.onCallNumbers)); - } - if (digits === "2" && config.aiNumbers.length > 0) { - return buildTwiml(buildDialTargets(config.aiNumbers)); - } - return buildMenuResponse(requestUrl, config); -} - -async function readDigits(request: Request): Promise { - if (request.method === "GET") return null; - const body = await request.text(); - if (!body) return null; - const params = new URLSearchParams(body); - return params.get("Digits") ?? params.get("digits"); -} - -/** - * Return the initial IVR menu for inbound Twilio calls. - * - * Purpose: present a stable entrypoint for customers so the Twilio number can - * route them either to on-call or to an optional AI assistant. - * - * Consumer: Twilio inbound call webhook and local integration tests. - */ -export async function GET(request: Request): Promise { - return new Response(buildMenuResponse(request.url, getRoutingConfig()), { - headers: noStoreHeaders(), - }); -} - -/** - * Route the Twilio call according to the selected IVR digit. - * - * Purpose: let the same public number handle both menu playback and the actual - * customer transfer without adding a second webhook surface. - * - * Consumer: Twilio posts DTMF selections back to this route after the caller - * chooses an IVR option. - */ -export async function POST(request: Request): Promise { - const digits = await readDigits(request); - const responseXml = - digits === null - ? buildMenuResponse(request.url, getRoutingConfig()) - : buildDigitsResponse(digits, request.url, getRoutingConfig()); - return new Response(responseXml, { - headers: noStoreHeaders(), - }); -}