sf snapshot: uncommitted changes after 197m inactivity

This commit is contained in:
Mikael Hugo 2026-05-10 15:59:33 +02:00
parent de77cf439f
commit 924383b6f7
33 changed files with 952 additions and 303 deletions

24
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,24 @@
# Copilot Instructions — singularity-forge
See [CLAUDE.md](../CLAUDE.md) for build pipeline details and test commands.
See [AGENTS.md](../AGENTS.md) for planning conventions and spec-first TDD doctrine.
## DB-first — non-negotiable
All state lives in SQLite via Node's built-in `node:sqlite` (`DatabaseSync`).
- **Never** use `better-sqlite3` or any native SQLite addon
- **Never** use file-based fallbacks for state that belongs in the DB (milestone context, sessions, memories, mode state, etc.)
- When checking if something "exists", query the DB — not the filesystem
- Sift indexes codebase files only; session/turn search uses FTS5 in `sf.db`
If a pattern uses files as a proxy for DB state (e.g., checking for `CONTEXT.md` instead of a DB row), treat that as a bug to fix, not a convention to follow.
## YOLO is a flag, not a mode
SF has exactly **two work modes**: **Ask** and **Build**.
- `Shift+Tab` cycles between Ask and Build
- **YOLO** (Ctrl+Y) is a flag layered on top of Build — it removes safety rails (no confirmations, no git prompts, full send)
- YOLO is never a Shift+Tab stop; it is not a third mode
- `/mode yolo` is equivalent to Ctrl+Y — it enables the flag, it doesn't switch modes

View file

@ -1,3 +1,3 @@
{ {
"lastFullVacuumAt": "2026-05-10T05:57:58.807Z" "lastFullVacuumAt": "2026-05-10T13:59:26.619Z"
} }

Binary file not shown.

Binary file not shown.

BIN
.sf/metrics.db-shm Normal file

Binary file not shown.

0
.sf/metrics.db-wal Normal file
View file

View file

@ -3,7 +3,7 @@
*/ */
import type { Api, Model } from "@singularity-forge/ai"; import type { Api, Model } from "@singularity-forge/ai";
import { fuzzyFilter } from "@singularity-forge/tui"; import { fuzzyFilter } from "@singularity-forge/tui/fuzzy";
import { getDiscoverableProviders } from "../core/model-discovery.js"; import { getDiscoverableProviders } from "../core/model-discovery.js";
import type { ModelRegistry } from "../core/model-registry.js"; import type { ModelRegistry } from "../core/model-registry.js";

View file

@ -363,6 +363,10 @@ function getAliases(): Record<string, string> {
"tui/dist/index.js", "tui/dist/index.js",
"@singularity-forge/tui", "@singularity-forge/tui",
), ),
"@singularity-forge/tui/fuzzy": resolveWorkspaceOrImport(
"tui/dist/fuzzy.js",
"@singularity-forge/tui/fuzzy",
),
"@singularity-forge/ai": resolveWorkspaceOrImport( "@singularity-forge/ai": resolveWorkspaceOrImport(
"ai/dist/index.js", "ai/dist/index.js",
"@singularity-forge/ai", "@singularity-forge/ai",
@ -383,6 +387,10 @@ function getAliases(): Record<string, string> {
"tui/dist/index.js", "tui/dist/index.js",
"@singularity-forge/tui", "@singularity-forge/tui",
), ),
"@mariozechner/tui/fuzzy": resolveWorkspaceOrImport(
"tui/dist/fuzzy.js",
"@singularity-forge/tui/fuzzy",
),
"@mariozechner/pi-ai": resolveWorkspaceOrImport( "@mariozechner/pi-ai": resolveWorkspaceOrImport(
"ai/dist/index.js", "ai/dist/index.js",
"@singularity-forge/ai", "@singularity-forge/ai",

View file

@ -1,8 +1,8 @@
import { type Model, modelsAreEqual } from "@singularity-forge/ai"; import { type Model, modelsAreEqual } from "@singularity-forge/ai";
import { fuzzyFilter } from "@singularity-forge/tui/fuzzy";
import { import {
Container, Container,
type Focusable, type Focusable,
fuzzyFilter,
getEditorKeybindings, getEditorKeybindings,
Input, Input,
Spacer, Spacer,

View file

@ -1,8 +1,8 @@
import type { Model } from "@singularity-forge/ai"; import type { Model } from "@singularity-forge/ai";
import { fuzzyFilter } from "@singularity-forge/tui/fuzzy";
import { import {
Container, Container,
type Focusable, type Focusable,
fuzzyFilter,
getEditorKeybindings, getEditorKeybindings,
Input, Input,
Key, Key,

View file

@ -1,4 +1,4 @@
import { fuzzyMatch } from "@singularity-forge/tui"; import { fuzzyMatch } from "@singularity-forge/tui/fuzzy";
import type { SessionInfo } from "../../../core/session-manager.js"; import type { SessionInfo } from "../../../core/session-manager.js";
export type SortMode = "threaded" | "recent" | "relevance"; export type SortMode = "threaded" | "recent" | "relevance";

View file

@ -26,11 +26,11 @@ import type {
OverlayOptions, OverlayOptions,
SlashCommand, SlashCommand,
} from "@singularity-forge/tui"; } from "@singularity-forge/tui";
import { fuzzyFilter } from "@singularity-forge/tui/fuzzy";
import { import {
CombinedAutocompleteProvider, CombinedAutocompleteProvider,
type Component, type Component,
Container, Container,
fuzzyFilter,
Loader, Loader,
Markdown, Markdown,
matchesKey, matchesKey,

View file

@ -561,6 +561,7 @@ function handleHotkeysCommand(ctx: SlashCommandContext): void {
const yank = getEditorKeyDisplay("yank"); const yank = getEditorKeyDisplay("yank");
const yankPop = getEditorKeyDisplay("yankPop"); const yankPop = getEditorKeyDisplay("yankPop");
const undo = getEditorKeyDisplay("undo"); const undo = getEditorKeyDisplay("undo");
const redo = getEditorKeyDisplay("redo");
const tab = getEditorKeyDisplay("tab"); const tab = getEditorKeyDisplay("tab");
// App keybindings // App keybindings
@ -612,6 +613,7 @@ function handleHotkeysCommand(ctx: SlashCommandContext): void {
| \`${yank}\` | Paste the most-recently-deleted text | | \`${yank}\` | Paste the most-recently-deleted text |
| \`${yankPop}\` | Cycle through the deleted text after pasting | | \`${yankPop}\` | Cycle through the deleted text after pasting |
| \`${undo}\` | Undo | | \`${undo}\` | Undo |
| \`${redo}\` | Redo |
**Other** **Other**
| Key | Action | | Key | Action |

View file

@ -10,6 +10,11 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js", "import": "./dist/index.js",
"require": "./dist/index.js" "require": "./dist/index.js"
},
"./fuzzy": {
"types": "./dist/fuzzy.d.ts",
"import": "./dist/fuzzy.js",
"require": "./dist/fuzzy.js"
} }
}, },
"scripts": { "scripts": {

View file

@ -0,0 +1,31 @@
// cell-size buffer drain — behaviour matches legacy TUI.parseCellSizeResponse
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { drainCellSizeQueryBuffer } from "../cell-size.js";
describe("drainCellSizeQueryBuffer", () => {
it("waits_on_incomplete_escape_tail", () => {
const r = drainCellSizeQueryBuffer("\x1b[6;10;10");
assert.equal(r.wait, true);
assert.equal(r.nextBuffer, "\x1b[6;10;10");
assert.equal(r.forwardInput, "");
});
it("forwards_bare_escape_after_pending_buffer", () => {
const r = drainCellSizeQueryBuffer("\x1b");
assert.equal(r.wait, false);
assert.equal(r.forwardInput, "\x1b");
assert.equal(r.nextBuffer, "");
});
it("strips_reported_cell_size_and_emits_cellPixels", () => {
const buf = "\x1b[6;20;40trest";
const r = drainCellSizeQueryBuffer(buf);
assert.ok(r.cellPixels);
assert.equal(r.cellPixels?.widthPx, 40);
assert.equal(r.cellPixels?.heightPx, 20);
assert.equal(r.forwardInput, "rest");
assert.equal(r.wait, false);
});
});

View file

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { syncHardwareCursorForIME } from "../hardware-cursor.js";
describe("syncHardwareCursorForIME", () => {
it("hides_cursor_when_no_marker", () => {
let hid = 0;
const term = {
hideCursor: () => {
hid++;
},
showCursor: () => {},
write: () => {},
};
const row = syncHardwareCursorForIME(term, {
cursorPos: null,
totalLines: 5,
hardwareCursorRow: 0,
showHardwareCursor: true,
});
assert.equal(hid, 1);
assert.equal(row, 0);
});
it("writes_row_and_column_escapes", () => {
const writes: string[] = [];
const term = {
hideCursor: () => {},
showCursor: () => {},
write: (s: string) => {
writes.push(s);
},
};
const next = syncHardwareCursorForIME(term, {
cursorPos: { row: 2, col: 3 },
totalLines: 10,
hardwareCursorRow: 0,
showHardwareCursor: false,
});
assert.equal(next, 2);
const joined = writes.join("");
assert.ok(joined.includes("\x1b[2B"));
assert.ok(joined.includes("\x1b[4G"));
});
});

View file

@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import {
buildStickyScrollVerticalEscapes,
isStickyViewportAtBottom,
} from "../viewport-sticky.js";
describe("viewport-sticky", () => {
it("isStickyViewportAtBottom_matches_maxLinesRendering", () => {
assert.equal(
isStickyViewportAtBottom({
maxLinesRendered: 100,
terminalRows: 24,
previousContentLineCount: 100,
}),
true,
);
assert.equal(
isStickyViewportAtBottom({
maxLinesRendered: 100,
terminalRows: 24,
previousContentLineCount: 150,
}),
false,
);
});
it("buildStickyScrollVerticalEscapes_null_when_content_fits", () => {
assert.equal(
buildStickyScrollVerticalEscapes({
contentLineCount: 10,
terminalRows: 24,
previousViewportTop: 0,
hardwareCursorRow: 0,
}),
null,
);
});
it("buildStickyScrollVerticalEscapes_emits_down_moves", () => {
const r = buildStickyScrollVerticalEscapes({
contentLineCount: 100,
terminalRows: 24,
previousViewportTop: 0,
hardwareCursorRow: 0,
});
assert.ok(r);
assert.ok(r!.escapes.includes("B"));
assert.ok(r!.nextPreviousViewportTop > 0);
});
});

View file

@ -35,3 +35,75 @@ export function parseCellSizeResponse(
export function stripCellSizeResponse(chunk: string): string { export function stripCellSizeResponse(chunk: string): string {
return chunk.replace(CELL_SIZE_PATTERN, ""); return chunk.replace(CELL_SIZE_PATTERN, "");
} }
/**
* Result of splitting stdin while a CSI 16 cell-size query response may arrive.
*
* Purpose: encapsulate buffering policy (bare Escape, incomplete CSI tails) while
* keeping `parseCellSizeResponse` / `stripCellSizeResponse` reusable as pure parsers.
*
* Consumer: `TUI.handleInput` when `cellSizeQueryPending` applies `cellPixels` via
* `setCellDimensions` when present, then honours `wait` / `forwardInput`.
*/
export interface DrainCellSizeQueryBufferResult {
/** Stored buffer state for subsequent chunks */
nextBuffer: string;
/**
* When the terminal reported pixel cell dimensions caller applies once (`setCellDimensions`)
* before `invalidate` / `requestRender`.
*/
cellPixels?: { widthPx: number; heightPx: number };
/**
* When true no keystrokes forwarded yet (partial escape still accumulating).
*/
wait: boolean;
/** Keyboard input unlocked from the pending buffer once `wait` is false */
forwardInput: string;
}
/**
* Consume stdin buffered after CSI 16 t was sent: extract optional pixel cell size,
* bare Escape forwarding, incomplete sequence detection (legacy `parseCellSizeResponse`).
*/
export function drainCellSizeQueryBuffer(
buffer: string,
): DrainCellSizeQueryBufferResult {
let cellPixels: DrainCellSizeQueryBufferResult["cellPixels"];
let buf = buffer;
const parsed = parseCellSizeResponse(buf);
if (parsed) {
const { height, width } = parsed;
cellPixels = { widthPx: width, heightPx: height };
buf = stripCellSizeResponse(buf);
}
if (buf === "\x1b") {
return {
nextBuffer: "",
cellPixels,
wait: false,
forwardInput: "\x1b",
};
}
const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
if (partialCellSizePattern.test(buf)) {
const lastChar = buf.length > 0 ? buf[buf.length - 1]! : "";
if (!/[a-zA-Z~]/.test(lastChar)) {
return {
nextBuffer: buf,
cellPixels,
wait: true,
forwardInput: "",
};
}
}
return {
nextBuffer: "",
cellPixels,
wait: false,
forwardInput: buf,
};
}

View file

@ -78,4 +78,21 @@ describe("Editor", () => {
assert.equal(editor.getText(), ""); assert.equal(editor.getText(), "");
}); });
it("redo_restores_after_undo_via_ctrl_shift_z_CSI_u", () => {
const editor = new Editor(new TUI(makeTerminal()), theme);
editor.focused = true;
editor.setText("");
for (const ch of "hello") {
editor.handleInput(ch);
}
assert.equal(editor.getText(), "hello");
editor.handleInput("\x1f"); // ctrl+-
assert.equal(editor.getText(), "");
editor.handleInput("\x1b[122;6u"); // ctrl+shift+z (Kitty CSI-u)
assert.equal(editor.getText(), "hello");
});
}); });

View file

@ -50,9 +50,8 @@ export class Input implements Component, Focusable {
private killRing = new KillRing(); private killRing = new KillRing();
private lastAction: "kill" | "yank" | "type-word" | null = null; private lastAction: "kill" | "yank" | "type-word" | null = null;
// Undo support // Undo / redo — single UndoStack matches Editor semantics (undo-stack.ts redo API).
private undoStack = new UndoStack<InputState>(); private undoStack = new UndoStack<InputState>();
private redoStack = new UndoStack<InputState>();
getValue(): string { getValue(): string {
return this.value; return this.value;
@ -376,23 +375,22 @@ export class Input implements Component, Focusable {
} }
private pushUndo(): void { private pushUndo(): void {
this.redoStack.clear();
this.undoStack.push({ value: this.value, cursor: this.cursor }); this.undoStack.push({ value: this.value, cursor: this.cursor });
} }
private undo(): void { private undo(): void {
const snapshot = this.undoStack.pop(); const snapshot = this.undoStack.pop();
if (!snapshot) return; if (!snapshot) return;
this.redoStack.push({ value: this.value, cursor: this.cursor }); this.undoStack.pushRedo({ value: this.value, cursor: this.cursor });
this.value = snapshot.value; this.value = snapshot.value;
this.cursor = snapshot.cursor; this.cursor = snapshot.cursor;
this.lastAction = null; this.lastAction = null;
} }
private redo(): void { private redo(): void {
const snapshot = this.redoStack.pop(); const snapshot = this.undoStack.redo();
if (!snapshot) return; if (!snapshot) return;
this.undoStack.push({ value: this.value, cursor: this.cursor }); this.undoStack.pushUndo({ value: this.value, cursor: this.cursor });
this.value = snapshot.value; this.value = snapshot.value;
this.cursor = snapshot.cursor; this.cursor = snapshot.cursor;
this.lastAction = null; this.lastAction = null;

View file

@ -0,0 +1,55 @@
import type { Terminal } from "./terminal.js";
/** Logical cursor row/col extracted from overlay-stripped rendered lines */
export type HardwareCursorLogicalPos = { row: number; col: number };
/**
* Move the terminal cursor so IME anchors to the rendered logical cursor (`CURSOR_MARKER`).
*
* Purpose: IME placement stays testable without the differential render pipeline.
*
* Consumer: `TUI.doRender` after marker strip (`extractCursorPosition`).
*
* Returns the next logical hardware cursor row used for incremental cursor-move math.
*/
export function syncHardwareCursorForIME(
terminal: Pick<Terminal, "hideCursor" | "showCursor" | "write">,
options: {
cursorPos: HardwareCursorLogicalPos | null;
totalLines: number;
hardwareCursorRow: number;
showHardwareCursor: boolean;
},
): number {
const { cursorPos, totalLines, hardwareCursorRow, showHardwareCursor } =
options;
if (!cursorPos || totalLines <= 0) {
terminal.hideCursor();
return hardwareCursorRow;
}
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
const targetCol = Math.max(0, cursorPos.col);
const rowDelta = targetRow - hardwareCursorRow;
let buffer = "";
if (rowDelta > 0) {
buffer += `\x1b[${rowDelta}B`;
} else if (rowDelta < 0) {
buffer += `\x1b[${-rowDelta}A`;
}
buffer += `\x1b[${targetCol + 1}G`;
if (buffer) {
terminal.write(buffer);
}
if (showHardwareCursor) {
terminal.showCursor();
} else {
terminal.hideCursor();
}
return targetRow;
}

View file

@ -46,6 +46,8 @@ export { Text } from "./components/text.js";
export { TruncatedText } from "./components/truncated-text.js"; export { TruncatedText } from "./components/truncated-text.js";
// Editor component interface (for custom editors) // Editor component interface (for custom editors)
export type { EditorComponent } from "./editor-component.js"; export type { EditorComponent } from "./editor-component.js";
// Fuzzy helpers are imported from `@singularity-forge/tui/fuzzy` (package subpath —
// avoids barrel regressions dropping exports). See package.json `"exports"` map.
// Ink bridge — gradual migration infrastructure // Ink bridge — gradual migration infrastructure
export { startInkRenderer } from "./ink-bridge.js"; export { startInkRenderer } from "./ink-bridge.js";
// Keybindings // Keybindings
@ -72,6 +74,8 @@ export {
} from "./keys.js"; } from "./keys.js";
// Render safety — prevents one failing component from blanking the TUI // Render safety — prevents one failing component from blanking the TUI
export { tryRender } from "./render-guard.js"; export { tryRender } from "./render-guard.js";
// TUI input listener — used with `TUI.addInputListener`
export type { InputListener } from "./tui-input-dispatch.js";
// Input buffering for batch splitting // Input buffering for batch splitting
export { export {
StdinBuffer, StdinBuffer,

View file

@ -5,9 +5,13 @@
* positions and composite overlay content onto base terminal lines. * positions and composite overlay content onto base terminal lines.
*/ */
import type {
OverlayAnchor,
OverlayOptions,
SizeValue,
} from "./overlay-types.js";
import { tryRender } from "./render-guard.js"; import { tryRender } from "./render-guard.js";
import { isImageLine } from "./terminal-image.js"; import { isImageLine } from "./terminal-image.js";
import type { OverlayAnchor, OverlayOptions, SizeValue } from "./tui.js";
import { CURSOR_MARKER } from "./tui.js"; import { CURSOR_MARKER } from "./tui.js";
import { import {
applyBackgroundToLine, applyBackgroundToLine,

View file

@ -0,0 +1,90 @@
/**
* Overlay sizing, anchoring, and handle types for layered TUI content.
*/
/** Margin configuration for overlays */
export interface OverlayMargin {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
/** Value that can be absolute (number) or percentage (string like "50%") */
export type SizeValue = number | `${number}%`;
/**
* Anchor position for overlays
*/
export type OverlayAnchor =
| "center"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
| "top-center"
| "bottom-center"
| "left-center"
| "right-center";
/**
* Options for overlay positioning and sizing.
* Values can be absolute numbers or percentage strings (e.g., "50%").
*/
export interface OverlayOptions {
// === Sizing ===
/** Width in columns, or percentage of terminal width (e.g., "50%") */
width?: SizeValue;
/** Minimum width in columns */
minWidth?: number;
/** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
maxHeight?: SizeValue;
// === Positioning - anchor-based ===
/** Anchor point for positioning (default: 'center') */
anchor?: OverlayAnchor;
/** Horizontal offset from anchor position (positive = right) */
offsetX?: number;
/** Vertical offset from anchor position (positive = down) */
offsetY?: number;
// === Positioning - percentage or absolute ===
/** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
row?: SizeValue;
/** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
col?: SizeValue;
// === Margin from terminal edges ===
/** Margin from terminal edges. Number applies to all sides. */
margin?: OverlayMargin | number;
// === Visibility ===
/**
* Control overlay visibility based on terminal dimensions.
* If provided, overlay is only rendered when this returns true.
* Called each render cycle with current terminal dimensions.
*/
visible?: (termWidth: number, termHeight: number) => boolean;
/** If true, don't capture keyboard focus when shown */
nonCapturing?: boolean;
/** If true, dim the background behind the overlay */
backdrop?: boolean;
}
/**
* Handle returned by showOverlay for controlling the overlay
*/
export interface OverlayHandle {
/** Permanently remove the overlay (cannot be shown again) */
hide(): void;
/** Temporarily hide or show the overlay */
setHidden(hidden: boolean): void;
/** Check if this overlay temporarily hidden */
isHidden(): boolean;
/** Focus this overlay and bring it to the visual front */
focus(): void;
/** Release focus to the previous target */
unfocus(): void;
/** Check if this overlay currently has focus */
isFocused(): boolean;
}

View file

@ -0,0 +1,34 @@
/**
* Small pure helpers for TUI stdin routing (`TUI.handleInput`).
*
* Purpose: keep listener fan-out isolated from viewport / overlay routing so
* `tui.ts` stays readable without changing behaviour.
*/
export type InputListenerResult =
| { consume?: boolean; data?: string }
| undefined;
export type InputListener = (data: string) => InputListenerResult;
/**
* Run stdin through each listener in insertion order until one consumes or
* rewrites `data`.
*
* Returns `undefined` when a listener consumes the chunk (caller should exit).
*/
export function applyInputListeners(
listeners: Set<InputListener>,
intake: string,
): string | undefined {
let current = intake;
for (const listener of listeners) {
const result = listener(current);
if (result?.consume) {
return undefined;
}
if (result?.data !== undefined) {
current = result.data;
}
}
return current.length === 0 ? undefined : current;
}

View file

@ -5,14 +5,12 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { import { drainCellSizeQueryBuffer } from "./cell-size.js";
parseCellSizeResponse as parseCellSizeResponseFn,
stripCellSizeResponse,
} from "./cell-size.js";
import { import {
type AutonomousModeStatus, type AutonomousModeStatus,
AutonomousStatusBar, AutonomousStatusBar,
} from "./components/autonomous-status-bar.js"; } from "./components/autonomous-status-bar.js";
import { syncHardwareCursorForIME } from "./hardware-cursor.js";
import { startInkRenderer } from "./ink-bridge.js"; import { startInkRenderer } from "./ink-bridge.js";
import { isKeyRelease, matchesKey } from "./keys.js"; import { isKeyRelease, matchesKey } from "./keys.js";
import { import {
@ -21,6 +19,7 @@ import {
extractCursorPosition, extractCursorPosition,
isOverlayVisible as isOverlayEntryVisible, isOverlayVisible as isOverlayEntryVisible,
} from "./overlay-layout.js"; } from "./overlay-layout.js";
import type { OverlayHandle, OverlayOptions } from "./overlay-types.js";
import { tryRender } from "./render-guard.js"; import { tryRender } from "./render-guard.js";
import type { Terminal } from "./terminal.js"; import type { Terminal } from "./terminal.js";
import { import {
@ -28,7 +27,15 @@ import {
isImageLine, isImageLine,
setCellDimensions, setCellDimensions,
} from "./terminal-image.js"; } from "./terminal-image.js";
import {
applyInputListeners,
type InputListener,
} from "./tui-input-dispatch.js";
import { truncateToWidth, visibleWidth } from "./utils.js"; import { truncateToWidth, visibleWidth } from "./utils.js";
import {
buildStickyScrollVerticalEscapes,
isStickyViewportAtBottom,
} from "./viewport-sticky.js";
/** /**
* Component interface - all components must implement this * Component interface - all components must implement this
@ -59,9 +66,6 @@ export interface Component {
invalidate(): void; invalidate(): void;
} }
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
type InputListener = (data: string) => InputListenerResult;
/** /**
* Interface for components that can receive focus and display a hardware cursor. * Interface for components that can receive focus and display a hardware cursor.
* When focused, the component should emit CURSOR_MARKER at the cursor position * When focused, the component should emit CURSOR_MARKER at the cursor position
@ -88,97 +92,15 @@ export function isFocusable(
*/ */
export const CURSOR_MARKER = "\x1b_sf:c\x07"; export const CURSOR_MARKER = "\x1b_sf:c\x07";
export type {
OverlayAnchor,
OverlayHandle,
OverlayMargin,
OverlayOptions,
SizeValue,
} from "./overlay-types.js";
export { visibleWidth }; export { visibleWidth };
/**
* Anchor position for overlays
*/
export type OverlayAnchor =
| "center"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
| "top-center"
| "bottom-center"
| "left-center"
| "right-center";
/**
* Margin configuration for overlays
*/
export interface OverlayMargin {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
/** Value that can be absolute (number) or percentage (string like "50%") */
export type SizeValue = number | `${number}%`;
/**
* Options for overlay positioning and sizing.
* Values can be absolute numbers or percentage strings (e.g., "50%").
*/
export interface OverlayOptions {
// === Sizing ===
/** Width in columns, or percentage of terminal width (e.g., "50%") */
width?: SizeValue;
/** Minimum width in columns */
minWidth?: number;
/** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
maxHeight?: SizeValue;
// === Positioning - anchor-based ===
/** Anchor point for positioning (default: 'center') */
anchor?: OverlayAnchor;
/** Horizontal offset from anchor position (positive = right) */
offsetX?: number;
/** Vertical offset from anchor position (positive = down) */
offsetY?: number;
// === Positioning - percentage or absolute ===
/** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
row?: SizeValue;
/** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
col?: SizeValue;
// === Margin from terminal edges ===
/** Margin from terminal edges. Number applies to all sides. */
margin?: OverlayMargin | number;
// === Visibility ===
/**
* Control overlay visibility based on terminal dimensions.
* If provided, overlay is only rendered when this returns true.
* Called each render cycle with current terminal dimensions.
*/
visible?: (termWidth: number, termHeight: number) => boolean;
/** If true, don't capture keyboard focus when shown */
nonCapturing?: boolean;
/** If true, dim the background behind the overlay */
backdrop?: boolean;
}
/**
* Handle returned by showOverlay for controlling the overlay
*/
export interface OverlayHandle {
/** Permanently remove the overlay (cannot be shown again) */
hide(): void;
/** Temporarily hide or show the overlay */
setHidden(hidden: boolean): void;
/** Check if overlay is temporarily hidden */
isHidden(): boolean;
/** Focus this overlay and bring it to the visual front */
focus(): void;
/** Release focus to the previous target */
unfocus(): void;
/** Check if this overlay currently has focus */
isFocused(): boolean;
}
/** /**
* Container - a component that contains other components * Container - a component that contains other components
*/ */
@ -605,34 +527,32 @@ export class TUI extends Container {
* Check if user is scrolled to the bottom of the content * Check if user is scrolled to the bottom of the content
*/ */
private isAtBottom(): boolean { private isAtBottom(): boolean {
const height = this.terminal.rows; return isStickyViewportAtBottom({
const viewportTop = Math.max(0, this.maxLinesRendered - height); maxLinesRendered: this.maxLinesRendered,
const viewportBottom = viewportTop + height; terminalRows: this.terminal.rows,
return viewportBottom >= this.previousLines.length; previousContentLineCount: this.previousLines.length,
});
} }
/** /**
* Scroll to bottom of content (sticky bottom) * Scroll to bottom of content (sticky bottom)
*/ */
private scrollToBottom(): void { private scrollToBottom(): void {
const height = this.terminal.rows; const sticky = buildStickyScrollVerticalEscapes({
const contentHeight = this.previousLines.length; contentLineCount: this.previousLines.length,
if (contentHeight <= height) return; // No scrolling needed if content fits in viewport terminalRows: this.terminal.rows,
previousViewportTop: this.previousViewportTop,
// For terminal scrolling, we can use cursor movement or scroll sequences hardwareCursorRow: this.hardwareCursorRow,
// The simplest approach is to move the cursor to the bottom line });
const viewportTop = Math.max(0, contentHeight - height); if (!sticky) {
const targetScreenRow = contentHeight - 1; return;
const currentScreenRow = this.hardwareCursorRow - this.previousViewportTop;
const lineDiff = targetScreenRow - currentScreenRow;
if (lineDiff > 0) {
this.terminal.write(`\x1b[${lineDiff}B`); // Move cursor down
} else if (lineDiff < 0) {
this.terminal.write(`\x1b[${-lineDiff}A`); // Move cursor up
} }
this.previousViewportTop = viewportTop; const { escapes, nextPreviousViewportTop } = sticky;
if (escapes) {
this.terminal.write(escapes);
}
this.previousViewportTop = nextPreviousViewportTop;
this.isScrolledToBottom = true; this.isScrolledToBottom = true;
} }
@ -644,39 +564,84 @@ export class TUI extends Container {
this.requestRender(); this.requestRender();
} }
private flushCellSizeBufferedInput(chunk: string): string | undefined {
this.inputBuffer += chunk;
const drained = drainCellSizeQueryBuffer(this.inputBuffer);
this.inputBuffer = drained.nextBuffer;
if (drained.cellPixels) {
setCellDimensions(drained.cellPixels);
this.invalidate();
this.requestRender();
}
if (drained.wait) {
return undefined;
}
this.cellSizeQueryPending = false;
if (drained.forwardInput.length === 0) {
return undefined;
}
return drained.forwardInput;
}
private repairFocusedOverlayVisibility(): void {
const focusedOverlay = this.overlayStack.find(
(o) => o.component === this.focusedComponent,
);
if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
const topVisible = this.getTopmostVisibleOverlay();
if (topVisible) {
this.setFocus(topVisible.component);
} else {
this.setFocus(focusedOverlay.preFocus);
}
}
}
private maybeHandleEnterAsScrollToBottom(data: string): boolean {
if (data !== "\r" && data !== "\n") {
return false;
}
if (!this.isAtBottom()) {
this.scrollToBottom();
return true;
}
return false;
}
private dispatchFocusedComponentInput(data: string): void {
if (!this.focusedComponent?.handleInput) {
return;
}
if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
return;
}
this.focusedComponent.handleInput(data);
this.requestRender();
}
private handleInput(data: string): void { private handleInput(data: string): void {
if (this.inputListeners.size > 0) { const afterListeners = applyInputListeners(this.inputListeners, data);
let current = data; if (afterListeners === undefined) {
for (const listener of this.inputListeners) {
const result = listener(current);
if (result?.consume) {
return; return;
} }
if (result?.data !== undefined) { data = afterListeners;
current = result.data;
}
}
if (current.length === 0) {
return;
}
data = current;
}
// If we're waiting for cell size response, buffer input and parse
if (this.cellSizeQueryPending) { if (this.cellSizeQueryPending) {
this.inputBuffer += data; const released = this.flushCellSizeBufferedInput(data);
const filtered = this.parseCellSizeResponse(); if (released === undefined) {
if (filtered.length === 0) return; return;
data = filtered; }
data = released;
} }
// Global debug key handler (Shift+Ctrl+D)
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) { if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
this.onDebug(); this.onDebug();
return; return;
} }
// Detect scrolling keys (Page Up/Down, arrow keys) to break sticky bottom
if ( if (
this.isScrolledToBottom && this.isScrolledToBottom &&
(matchesKey(data, "pageUp") || matchesKey(data, "up")) (matchesKey(data, "pageUp") || matchesKey(data, "up"))
@ -684,88 +649,13 @@ export class TUI extends Container {
this.isScrolledToBottom = false; this.isScrolledToBottom = false;
} }
// If focused component is an overlay, verify it's still visible this.repairFocusedOverlayVisibility();
// (visibility can change due to terminal resize or visible() callback)
const focusedOverlay = this.overlayStack.find(
(o) => o.component === this.focusedComponent,
);
if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
// Focused overlay is no longer visible, redirect to topmost visible overlay
const topVisible = this.getTopmostVisibleOverlay();
if (topVisible) {
this.setFocus(topVisible.component);
} else {
// No visible overlays, restore to preFocus
this.setFocus(focusedOverlay.preFocus);
}
}
// Enter key scrolling behavior: if not at bottom, scroll down instead of sending input if (this.maybeHandleEnterAsScrollToBottom(data)) {
if (data === "\r" || data === "\n") {
// Enter key
if (!this.isAtBottom()) {
// Scroll down one page or to bottom
this.scrollToBottom();
return; return;
} }
// If we're at bottom, let Enter pass through to focused component
}
// Pass input to focused component (including Ctrl+C) this.dispatchFocusedComponentInput(data);
// The focused component can decide how to handle Ctrl+C
if (this.focusedComponent?.handleInput) {
// Filter out key release events unless component opts in
if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
return;
}
this.focusedComponent.handleInput(data);
this.requestRender();
}
}
private parseCellSizeResponse(): string {
// Response format: ESC [ 6 ; height ; width t — parsed via cell-size.ts
const parsed = parseCellSizeResponseFn(this.inputBuffer);
if (parsed) {
const { height: heightPx, width: widthPx } = parsed;
setCellDimensions({ widthPx, heightPx });
// Invalidate all components so images re-render with correct dimensions
this.invalidate();
this.requestRender();
// Remove the response from buffer
this.inputBuffer = stripCellSizeResponse(this.inputBuffer);
this.cellSizeQueryPending = false;
}
// Don't hold a bare Escape keypress hostage while waiting for the
// optional cell-size response. This is the most common early input race.
if (this.inputBuffer === "\x1b") {
const result = this.inputBuffer;
this.inputBuffer = "";
this.cellSizeQueryPending = false;
return result;
}
// Check if we have a partial cell size response starting (wait for more data)
// Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
if (partialCellSizePattern.test(this.inputBuffer)) {
// Check if it's actually a complete different escape sequence (ends with a letter)
// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
if (!/[a-zA-Z~]/.test(lastChar)) {
// Doesn't end with a terminator, might be incomplete - wait for more
return "";
}
}
// No cell size response found, return buffered data as user input
const result = this.inputBuffer;
this.inputBuffer = "";
this.cellSizeQueryPending = false; // Give up waiting
return result;
} }
private doRender(): void { private doRender(): void {
@ -854,7 +744,7 @@ export class TUI extends Container {
); );
} }
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
this.positionHardwareCursor(cursorPos, newLines.length); this.applyIMEHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines; this.previousLines = newLines;
this.previousWidth = width; this.previousWidth = width;
this.previousHeight = height; this.previousHeight = height;
@ -947,7 +837,7 @@ export class TUI extends Container {
// No changes - but still need to update hardware cursor position if it moved // No changes - but still need to update hardware cursor position if it moved
if (firstChanged === -1) { if (firstChanged === -1) {
this.positionHardwareCursor(cursorPos, newLines.length); this.applyIMEHardwareCursor(cursorPos, newLines.length);
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
this.previousHeight = height; this.previousHeight = height;
return; return;
@ -985,7 +875,7 @@ export class TUI extends Container {
this.cursorRow = targetRow; this.cursorRow = targetRow;
this.hardwareCursorRow = targetRow; this.hardwareCursorRow = targetRow;
} }
this.positionHardwareCursor(cursorPos, newLines.length); this.applyIMEHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines; this.previousLines = newLines;
this.previousWidth = width; this.previousWidth = width;
this.previousHeight = height; this.previousHeight = height;
@ -1124,7 +1014,7 @@ export class TUI extends Container {
} }
// Position hardware cursor for IME // Position hardware cursor for IME
this.positionHardwareCursor(cursorPos, newLines.length); this.applyIMEHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines; this.previousLines = newLines;
this.previousWidth = width; this.previousWidth = width;
@ -1138,43 +1028,17 @@ export class TUI extends Container {
} }
/** /**
* Position the hardware cursor for IME candidate window. * Position the hardware cursor for IME (`CURSOR_MARKER` anchor).
* @param cursorPos The cursor position extracted from rendered output, or null
* @param totalLines Total number of rendered lines
*/ */
private positionHardwareCursor( private applyIMEHardwareCursor(
cursorPos: { row: number; col: number } | null, cursorPos: { row: number; col: number } | null,
totalLines: number, totalLines: number,
): void { ): void {
if (!cursorPos || totalLines <= 0) { this.hardwareCursorRow = syncHardwareCursorForIME(this.terminal, {
this.terminal.hideCursor(); cursorPos,
return; totalLines,
} hardwareCursorRow: this.hardwareCursorRow,
showHardwareCursor: this.showHardwareCursor,
// Clamp cursor position to valid range });
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
const targetCol = Math.max(0, cursorPos.col);
// Move cursor from current position to target
const rowDelta = targetRow - this.hardwareCursorRow;
let buffer = "";
if (rowDelta > 0) {
buffer += `\x1b[${rowDelta}B`; // Move down
} else if (rowDelta < 0) {
buffer += `\x1b[${-rowDelta}A`; // Move up
}
// Move to absolute column (1-indexed)
buffer += `\x1b[${targetCol + 1}G`;
if (buffer) {
this.terminal.write(buffer);
}
this.hardwareCursorRow = targetRow;
if (this.showHardwareCursor) {
this.terminal.showCursor();
} else {
this.terminal.hideCursor();
}
} }
} }

View file

@ -0,0 +1,57 @@
/**
* Viewport calculations for sticky-bottom scrolling in the differential TUI renderer.
*
* Purpose: isolate arithmetic used by sticky-bottom scrolling so behaviour stays
* explicit and easier to regression-test.
*/
/**
* Whether the bottom of the content is visible in the working viewport (`TUI.isAtBottom`).
*/
export function isStickyViewportAtBottom(params: {
maxLinesRendered: number;
terminalRows: number;
previousContentLineCount: number;
}): boolean {
const { maxLinesRendered, terminalRows, previousContentLineCount } = params;
const height = terminalRows;
const viewportTop = Math.max(0, maxLinesRendered - height);
const viewportBottom = viewportTop + height;
return viewportBottom >= previousContentLineCount;
}
/**
* CSI escapes to reposition the cursor vertically for sticky-bottom (`TUI.scrollToBottom`).
*/
export function buildStickyScrollVerticalEscapes(params: {
contentLineCount: number;
terminalRows: number;
previousViewportTop: number;
hardwareCursorRow: number;
}): { escapes: string; nextPreviousViewportTop: number } | null {
const {
contentLineCount,
terminalRows,
previousViewportTop,
hardwareCursorRow,
} = params;
const height = terminalRows;
if (contentLineCount <= height) {
return null;
}
const viewportTop = Math.max(0, contentLineCount - height);
const targetScreenRow = contentLineCount - 1;
const currentScreenRow = hardwareCursorRow - previousViewportTop;
const lineDiff = targetScreenRow - currentScreenRow;
let escapes = "";
if (lineDiff > 0) {
escapes += `\x1b[${lineDiff}B`;
} else if (lineDiff < 0) {
escapes += `\x1b[${-lineDiff}A`;
}
return { escapes, nextPreviousViewportTop: viewportTop };
}

View file

@ -75,7 +75,11 @@ function hasTsFilesRecursive(dir) {
const hasTsFiles = hasTsFilesRecursive(srcResources); const hasTsFiles = hasTsFilesRecursive(srcResources);
if (hasTsFiles) { if (hasTsFiles) {
const tsgoBin = require.resolve("@typescript/native-preview/bin/tsgo.js"); const tsgoBin = join(
dirname(require.resolve("@typescript/native-preview/package.json")),
"bin",
"tsgo.js",
);
const compile = spawnSync( const compile = spawnSync(
process.execPath, process.execPath,
[tsgoBin, "--project", resourcesTsconfig], [tsgoBin, "--project", resourcesTsconfig],

View file

@ -19,6 +19,7 @@ This skill is sf-internal only. **Do not** shell out to external `claude`, `code
| Multi-stakeholder critique of a milestone roadmap | Debate mode or parallel swarm: PM / User / Combatant / Architect / Specialist (58 subagents). | | Multi-stakeholder critique of a milestone roadmap | Debate mode or parallel swarm: PM / User / Combatant / Architect / Specialist (58 subagents). |
| Pre-execution gate evaluation | sf's built-in Q3 / Q4 gates — already wired in `gate-evaluate.md`. | | Pre-execution gate evaluation | sf's built-in Q3 / Q4 gates — already wired in `gate-evaluate.md`. |
| Post-execution milestone review | sf's `validate-milestone` — already 3 parallel reviewers. | | Post-execution milestone review | sf's `validate-milestone` — already 3 parallel reviewers. |
| Iterative refinement of a result without re-briefing | Background single + `write_subagent` / `read_subagent` (multi-turn). |
Don't dispatch a subagent for tasks the parent agent can do in 23 tool calls. Subagent overhead beats parent-agent work only when the task is large enough or the parallelism actually buys something. Don't dispatch a subagent for tasks the parent agent can do in 23 tool calls. Subagent overhead beats parent-agent work only when the task is large enough or the parallelism actually buys something.
@ -170,6 +171,35 @@ Use `model` only when you need an explicit override:
{ agent: "reviewer", task: "...", model: "claude-sonnet-4-5" } { agent: "reviewer", task: "...", model: "claude-sonnet-4-5" }
``` ```
### Multi-turn background (write_subagent / read_subagent)
Single-mode background jobs support iterative refinement without re-establishing context.
```
// Turn 0: launch
subagent({ agent: "researcher", task: "Analyse X. Return findings.", background: true })
// → "Background job started: sub_a1b2c3d4"
// After await_subagent returns:
write_subagent({ job_id: "sub_a1b2c3d4", message: "Focus on the Y aspect and expand." })
// → "Follow-up dispatched on sub_a1b2c3d4 (turn 2). Use await_subagent to get the response."
// After second await_subagent:
read_subagent({ job_id: "sub_a1b2c3d4" })
// → Full turn history: agent (turn 0), user (turn 1), agent (turn 2)
// Read only new turns:
read_subagent({ job_id: "sub_a1b2c3d4", since_turn: 2 })
```
**How it works:** The subagent receives the original task + full conversation history + new message. The agent retains the role/context of the original dispatch — no re-briefing needed.
**Constraints:**
- Only available for single-mode jobs (`agent` + `task`, no `tasks`/`chain`).
- `write_subagent` on a running job is rejected — wait for completion first.
- Counts against the same `maxRunning` concurrency limit as new jobs.
- The conversation history is injected into the task string. For very long conversations (10+ turns, multi-KB each), prefer starting a fresh subagent with a synthesised briefing to avoid hitting OS argument length limits.
## Patterns ## Patterns
### Pattern 1 — Parallel research ### Pattern 1 — Parallel research

View file

@ -25,6 +25,8 @@ export function resolve(specifier, context, nextResolve) {
specifier = new URL("packages/agent-core/src/index.ts", ROOT).href; specifier = new URL("packages/agent-core/src/index.ts", ROOT).href;
} else if (specifier === "@singularity-forge/tui") { } else if (specifier === "@singularity-forge/tui") {
specifier = new URL("packages/tui/src/index.ts", ROOT).href; specifier = new URL("packages/tui/src/index.ts", ROOT).href;
} else if (specifier === "@singularity-forge/tui/fuzzy") {
specifier = new URL("packages/tui/src/fuzzy.ts", ROOT).href;
} else if (specifier === "@singularity-forge/native") { } else if (specifier === "@singularity-forge/native") {
specifier = new URL("packages/native/src/index.ts", ROOT).href; specifier = new URL("packages/native/src/index.ts", ROOT).href;
} else if (specifier.startsWith("@singularity-forge/native/")) { } else if (specifier.startsWith("@singularity-forge/native/")) {
@ -53,12 +55,19 @@ export function resolve(specifier, context, nextResolve) {
} else { } else {
candidate = specifier.replace(/\.js$/, ".ts"); candidate = specifier.replace(/\.js$/, ".ts");
} }
// Only rewrite if the .ts file exists const candidatePathTs = fileURLToPath(
const candidatePath = fileURLToPath(
new URL(candidate, context.parentURL), new URL(candidate, context.parentURL),
); );
if (existsSync(candidatePath)) { if (existsSync(candidatePathTs)) {
specifier = candidate; specifier = candidate;
} else {
const candidateTsx = candidate.replace(/\.ts$/, ".tsx");
const candidatePathTsx = fileURLToPath(
new URL(candidateTsx, context.parentURL),
);
if (existsSync(candidatePathTsx)) {
specifier = candidateTsx;
}
} }
} }
} }

View file

@ -12,7 +12,7 @@ export class SubagentBackgroundJobManager {
this.evictionMs = options.evictionMs ?? 10 * 60 * 1000; this.evictionMs = options.evictionMs ?? 10 * 60 * 1000;
this.onJobComplete = options.onJobComplete; this.onJobComplete = options.onJobComplete;
} }
register(label, runFn) { register(label, runFn, dispatchContext = null) {
const running = this.getRunningJobs(); const running = this.getRunningJobs();
if (running.length >= this.maxRunning) { if (running.length >= this.maxRunning) {
throw new Error( throw new Error(
@ -38,26 +38,74 @@ export class SubagentBackgroundJobManager {
label, label,
abortController, abortController,
promise: undefined, promise: undefined,
turns: [],
dispatchContext,
}; };
job.promise = runFn(abortController.signal) job.promise = this._attachRun(job, runFn);
this.jobs.set(id, job);
return id;
}
/** @internal Attaches a runFn to a job and wires up completion/failure. */
_attachRun(job, runFn) {
return runFn(job.abortController.signal)
.then((result) => { .then((result) => {
job.result = result; job.result = result;
job.status = result.isError ? "failed" : "completed"; job.status = result.isError ? "failed" : "completed";
this.scheduleEviction(id); this.scheduleEviction(job.id);
this.deliverResult(job); this.deliverResult(job);
}) })
.catch((err) => { .catch((err) => {
if (job.status === "cancelled") { if (job.status === "cancelled") {
this.scheduleEviction(id); this.scheduleEviction(job.id);
return; return;
} }
job.status = "failed"; job.status = "failed";
job.errorText = err instanceof Error ? err.message : String(err); job.errorText = err instanceof Error ? err.message : String(err);
this.scheduleEviction(id); this.scheduleEviction(job.id);
this.deliverResult(job); this.deliverResult(job);
}); });
this.jobs.set(id, job); }
return id; /**
* Resume a completed (or failed) job with a new runFn.
* Used by write_subagent for multi-turn conversations.
* Returns "resumed" | "not_found" | "already_running" | "concurrency_limit"
*/
resume(id, runFn) {
const job = this.jobs.get(id);
if (!job) return "not_found";
if (job.status === "running") return "already_running";
if (this.getRunningJobs().length >= this.maxRunning) return "concurrency_limit";
// Cancel pending eviction — job is active again
const evTimer = this.evictionTimers.get(id);
if (evTimer) {
clearTimeout(evTimer);
this.evictionTimers.delete(id);
}
// Cancel pending delivery notification (avoids stale follow-up firing mid-run)
if (job.deliveryTimer !== undefined) {
clearTimeout(job.deliveryTimer);
job.deliveryTimer = undefined;
}
// Fresh AbortController — previous one may be spent/aborted
job.abortController = new AbortController();
job.status = "running";
job.awaited = false;
job.result = undefined;
job.errorText = undefined;
job.promise = this._attachRun(job, runFn);
return "resumed";
}
/** Append a conversation turn (role: "agent" | "user") to the job's turn history. */
appendTurn(id, role, content) {
const job = this.jobs.get(id);
if (!job) return;
job.turns.push({ turnIndex: job.turns.length, role, content, timestamp: Date.now() });
}
/** Return all turns for a job, optionally filtered to those after `since` (exclusive). */
getTurns(id, since = 0) {
const job = this.jobs.get(id);
if (!job) return null;
return job.turns.slice(since);
} }
cancel(id) { cancel(id) {
const job = this.jobs.get(id); const job = this.jobs.get(id);

View file

@ -1777,9 +1777,36 @@ export default function (pi) {
} }
if (params.background) { if (params.background) {
const manager = getBackgroundJobs(); const manager = getBackgroundJobs();
const jobId = manager.register( // Build a rerun factory for write_subagent multi-turn follow-ups.
summarizeBackgroundInvocation(params), // Only single-mode dispatches (params.agent + params.task) support write_subagent.
(backgroundSignal) => const isSingleMode = Boolean(params.agent && params.task && !params.tasks && !params.chain);
const dispatchContext = isSingleMode
? {
originalTask: params.task,
rerunWithTask: (enrichedTask, signal) =>
executeSubagentInvocation({
defaultCwd: ctx.cwd,
agents,
agentScope,
projectAgentsDir: discovery.projectAgentsDir,
params: {
...params,
task: enrichedTask,
confirmProjectAgents: false,
background: false,
},
signal,
onUpdate: undefined,
cmuxClient,
cmuxSplitsEnabled,
useIsolation,
inheritanceEnvelope,
}),
}
: null;
// Use a ref so the wrapped runFn can append the agent turn after completion.
let jobId;
const wrappedRun = (backgroundSignal) =>
executeSubagentInvocation({ executeSubagentInvocation({
defaultCwd: ctx.cwd, defaultCwd: ctx.cwd,
agents, agents,
@ -1796,8 +1823,15 @@ export default function (pi) {
cmuxSplitsEnabled, cmuxSplitsEnabled,
useIsolation, useIsolation,
inheritanceEnvelope, inheritanceEnvelope,
}), }).then((result) => {
); // Append agent turn BEFORE deliverResult fires the notification.
if (jobId && isSingleMode) {
const text = getPrimaryTextContent(result);
if (text) manager.appendTurn(jobId, "agent", text);
}
return result;
});
jobId = manager.register(summarizeBackgroundInvocation(params), wrappedRun, dispatchContext);
return { return {
content: [ content: [
{ {
@ -1805,7 +1839,7 @@ export default function (pi) {
text: text:
`Background subagent job started: **${jobId}**\n` + `Background subagent job started: **${jobId}**\n` +
`Invocation: \`${summarizeBackgroundInvocation(params)}\`\n\n` + `Invocation: \`${summarizeBackgroundInvocation(params)}\`\n\n` +
"Use `await_subagent` to retrieve the result or `cancel_subagent` to stop it.", "Use `await_subagent` to retrieve the result, `write_subagent` to send a follow-up, or `cancel_subagent` to stop it.",
}, },
], ],
details: undefined, details: undefined,
@ -2691,4 +2725,163 @@ export default function (pi) {
}; };
}, },
}); });
pi.registerTool({
name: "write_subagent",
label: "Write Subagent",
description:
"Send a follow-up message to a completed background subagent job, resuming the conversation. " +
"Only available for single-mode jobs (agent + task). " +
"The subagent receives the original task, the full conversation history, and the new message. " +
"Returns immediately — use await_subagent to retrieve the response.",
parameters: Type.Object({
job_id: Type.String({
description: "Background subagent job ID (for example sub_a1b2c3d4)",
}),
message: Type.String({
description: "Follow-up message or instruction to send to the subagent.",
}),
}),
async execute(_toolCallId, params) {
const manager = getBackgroundJobs();
const job = manager.getJob(params.job_id);
if (!job) {
return {
content: [{ type: "text", text: `Background subagent job not found: ${params.job_id}` }],
details: undefined,
isError: true,
};
}
if (job.status === "running") {
return {
content: [
{
type: "text",
text: `Background subagent ${params.job_id} is still running. Wait for it to complete before sending a follow-up.`,
},
],
details: undefined,
isError: true,
};
}
if (!job.dispatchContext?.rerunWithTask) {
return {
content: [
{
type: "text",
text: `Background subagent ${params.job_id} was launched in batch/chain mode and does not support write_subagent. Only single-mode (agent + task) jobs support multi-turn.`,
},
],
details: undefined,
isError: true,
};
}
// Append the user's follow-up turn to the history
manager.appendTurn(params.job_id, "user", params.message);
// Build an enriched task string: original task + full conversation history + new message
const allTurns = manager.getTurns(params.job_id);
// allTurns includes the just-appended user turn; exclude it from the "history" block
const historyTurns = allTurns.slice(0, -1);
let enrichedTask = job.dispatchContext.originalTask;
if (historyTurns.length > 0) {
const historyText = historyTurns
.map((t) => `[${t.role === "agent" ? "Agent" : "User"} — turn ${t.turnIndex}]\n${t.content}`)
.join("\n\n");
enrichedTask += `\n\n---\nConversation history:\n${historyText}\n---`;
}
enrichedTask += `\n\nUser follow-up: ${params.message}`;
const resumeResult = manager.resume(
params.job_id,
(signal) => job.dispatchContext.rerunWithTask(enrichedTask, signal).then((result) => {
// Append the new agent response as a turn before the notification fires
const text = getPrimaryTextContent(result);
if (text) manager.appendTurn(params.job_id, "agent", text);
return result;
}),
);
if (resumeResult !== "resumed") {
const messages = {
concurrency_limit: `Cannot resume ${params.job_id}: maximum concurrent background jobs reached. Cancel another job first.`,
not_found: `Background subagent job not found: ${params.job_id}`,
already_running: `Background subagent ${params.job_id} is already running.`,
};
return {
content: [{ type: "text", text: messages[resumeResult] ?? `Resume failed: ${resumeResult}` }],
details: undefined,
isError: true,
};
}
return {
content: [
{
type: "text",
text:
`Follow-up dispatched on **${params.job_id}** (turn ${allTurns.length}).\n\n` +
`Use \`await_subagent\` to retrieve the response or \`read_subagent\` to see the full conversation.`,
},
],
details: undefined,
};
},
});
pi.registerTool({
name: "read_subagent",
label: "Read Subagent",
description:
"Read the conversation turn history for a background subagent job. " +
"Returns all turns (agent responses and user follow-ups) since the job was started, " +
"optionally filtered to turns after a given index.",
parameters: Type.Object({
job_id: Type.String({
description: "Background subagent job ID (for example sub_a1b2c3d4)",
}),
since_turn: Type.Optional(
Type.Integer({
description:
"Return only turns with turnIndex >= this value. Omit to return all turns.",
minimum: 0,
}),
),
}),
async execute(_toolCallId, params) {
const manager = getBackgroundJobs();
const job = manager.getJob(params.job_id);
if (!job) {
return {
content: [{ type: "text", text: `Background subagent job not found: ${params.job_id}` }],
details: undefined,
isError: true,
};
}
const since = params.since_turn ?? 0;
const turns = manager.getTurns(params.job_id, since);
if (!turns || turns.length === 0) {
const status = job.status === "running" ? " (still running)" : ` (${job.status})`;
return {
content: [
{
type: "text",
text: `No turns found for ${params.job_id}${status}${since > 0 ? ` since turn ${since}` : ""}.`,
},
],
details: undefined,
};
}
const formatted = turns
.map((t) => {
const label = t.role === "agent" ? "🤖 Agent" : "👤 User";
return `**${label} [turn ${t.turnIndex}]**\n${t.content}`;
})
.join("\n\n---\n\n");
const statusLine = job.status === "running" ? " *(running)*" : ` *(${job.status})*`;
return {
content: [
{
type: "text",
text: `**${params.job_id}**${statusLine}${turns.length} turn(s)${since > 0 ? ` since turn ${since}` : ""}:\n\n${formatted}`,
},
],
details: undefined,
};
},
});
} }

View file

@ -70,6 +70,10 @@ export default defineConfig({
__dirname, __dirname,
"packages/agent-core/src/index.ts", "packages/agent-core/src/index.ts",
), ),
"@singularity-forge/tui/fuzzy": resolve(
__dirname,
"packages/tui/src/fuzzy.ts",
),
"@singularity-forge/tui": resolve( "@singularity-forge/tui": resolve(
__dirname, __dirname,
"packages/tui/src/index.ts", "packages/tui/src/index.ts",