Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ In TUI chat mode there is no completion gate — the session stays open across t
- Wires `ask_operator` to an operator-gate event resolved by a modal
- Mounts the OpenTUI host via `mountRunnerHost` (`src/tui/runner/host.ts`), which mounts `mountProductHost` (`src/tui/product-host.ts`) over the shell (`src/tui/shell/`)
- Bridges reactor events to the OpenTUI host via a plain `EventEmitter`
- **Mid-run injection** — Shell `session-queue` items drain at the parent
- **Mid-run injection** — Shell `delivery-queue` items drain at the parent
`tool.boundary` through `SessionPort.deliver`. Production `routeQueuedDelivery`
live-injects in-flight parent-boundary steers via `agentProxy.deliver`
(`Agent.deliver`) into one captured agent identity — never re-reading
Expand Down Expand Up @@ -220,7 +220,7 @@ The **`spawn_agent`** tool starts a fleet agent on a separate inference source (

Workers ask the spawning parent with **`ask_director`** (not the human). That parks a question while the worker stays `running`. On the TUI primary that arrives as an idle-send wake. On a nested orchestrator the question arrives as mailbox mail with an `awaiting_director` status — that is not terminal. Once a parked ask is surfaced (TUI wake, nested mailbox mail, or a successful `list_agents`), further `list_agents` calls fail closed until **`send_input`** answers or the ask is dropped — `list_agents` is not a poll. The parent answers with **`send_input`**, then continues. Escalate to the human with **`ask_operator`** only when the parent cannot resolve it.

When the parent TUI is not blocked in an in-flight tool call, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through queued-delivery's idle-send path without entering the user follow-up queue or composer `/feedback` capture.
When the parent TUI is not blocked in an in-flight tool call, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through delivery-queue's idle-send path without entering the user follow-up queue or composer `/feedback` capture.

When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `spawn_agent(agent=...)`. Default results are id, description, and spawn metadata (orchestrator flag, source). Pass `include_body=true` to include each match's loaded system prompt / body (truncated) so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `spawn_agent` and `search_agents` are core tools on the primary session.

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ the directors guard on; the full set of reactor and stream event types is
treat that as canonical rather than this section or any other doc's partial
list.

Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/session-queue.ts` (interaction contract §3): `enqueue` (kind `"queue"`) and `enqueueSteer` (kind `"steer"`) share one pending pool, drained steer-first, then queue, both FIFO within their class. Mid-run gestures: Enter soft-steers (drain at the next **parent** `tool.boundary` — the parent tool finishing, not a child; parent-busy holds steers), Alt+Enter queues a follow-up (drain on **session-idle**: parent-idle and no live fleet lanes), Ctrl+C stops. Idle-with-fleet is shipped: with live fleet lanes the bridge holds the run busy after the parent turn settles (`fleet` events carry the live count), mid-hold Enter upgrades to an immediate new turn, and the last lane terminalizing releases the hold and drains follow-ups.
Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/delivery-queue.ts` (interaction contract §3): `enqueue` (kind `"queue"`) and `enqueueSteer` (kind `"steer"`) share one pending pool, drained steer-first, then queue, both FIFO within their class. Mid-run gestures: Enter soft-steers (drain at the next **parent** `tool.boundary` — the parent tool finishing, not a child; parent-busy holds steers), Alt+Enter queues a follow-up (drain on **session-idle**: parent-idle and no live fleet lanes), Ctrl+C stops. Idle-with-fleet is shipped: with live fleet lanes the bridge holds the run busy after the parent turn settles (`fleet` events carry the live count), mid-hold Enter upgrades to an immediate new turn, and the last lane terminalizing releases the hold and drains follow-ups.

### Lifecycle Hooks

Expand Down
4 changes: 2 additions & 2 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ row at a time down to its 3-row base — never the transcript
(`COLLAPSE_ORDER` in `zones.ts`).

Horizontally, every surface sits inside one shared gutter
(`resolveSideMargin`, `src/tui/geometry/margins.ts`) so the shell reads
(`resolveSideMargin`, `src/tui/geometry/zones.ts`) so the shell reads
as a single column of content rather than stacked panes.
`resolveGeometry` always returns `layoutMode: "stack"` — full-width
y-stack, no dual-column rail. Live workers paint in the agents strip
Expand Down Expand Up @@ -711,7 +711,7 @@ thing they were trying to deliver. It now reports `interrupt — N pending
kept`: the run stops, the queue survives, and those messages are handed over
at the interrupt itself (`doInterrupt` drains after `port.interrupt()`), not
left waiting on an idle event the stop may never produce (`interrupt` in
`session-queue.ts` no longer clears `items`).
`delivery-queue.ts` no longer clears `items`).

**Fleet agent lanes on redirect.** Soft steer (Enter mid-run) and follow-up
(queued drain) leave running workers alone — they never call
Expand Down
34 changes: 13 additions & 21 deletions src/agent/agent-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import type { AgentTool } from "@intx/agent";
import type { ToolDefinition } from "@intx/types/runtime";
import { type } from "arktype";
import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js";
import {
lexicalFields,
scoreLexical,
tokenizeLexical,
} from "./lexical-rank.js";
import type { AgentProfile } from "./profiles.js";

function tokenize(text: string): string[] {
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}

function profileSearchText(profile: AgentProfile): string {
const parts = [
profile.id,
Expand All @@ -30,27 +31,18 @@ export function createAgentIndex(
profile: AgentProfile,
queryTokens: string[],
rawQuery: string,
): number => {
const idTokens = tokenize(profile.id);
const blob = profileSearchText(profile).toLowerCase();
const blobTokens = new Set(tokenize(blob));
let total = 0;
for (const token of queryTokens) {
if (idTokens.includes(token)) total += 3;
else if (blobTokens.has(token)) total += 1;
else if (profile.id.toLowerCase().includes(token)) total += 0.75;
else if (blob.includes(token)) total += 0.25;
}
if (profile.id.toLowerCase().includes(rawQuery)) total += 1;
if ((profile.description ?? "").toLowerCase().includes(rawQuery))
total += 0.5;
return total;
};
): number =>
scoreLexical(
lexicalFields(profile.id, profileSearchText(profile)),
queryTokens,
rawQuery,
) +
((profile.description ?? "").toLowerCase().includes(rawQuery) ? 0.5 : 0);

return {
search(query: string, limit = 12): AgentProfile[] {
const rawQuery = query.toLowerCase().trim();
const queryTokens = tokenize(query);
const queryTokens = tokenizeLexical(query);
const profiles = getProfiles();
if (queryTokens.length === 0) return profiles.slice(0, limit);
return profiles
Expand Down
1 change: 0 additions & 1 deletion src/agent/directors/bruckheimer/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/builder/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/counsel/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/critic/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/draper/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/emil/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/explorer/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/gaasbot/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/gauntlet/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/greybeard/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/intern/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/migrator/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/neckbeard/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/prober/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/rand/index.ts

This file was deleted.

40 changes: 20 additions & 20 deletions src/agent/directors/registry.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import type { AgentProfile, CapabilityFilter } from "../profile-types.js";
import { randPackage } from "./rand/index.js";
import { bruckheimerPackage } from "./bruckheimer/index.js";
import { criticPackage } from "./critic/index.js";
import { draperPackage } from "./draper/index.js";
import { emilPackage } from "./emil/index.js";
import { explorerPackage } from "./explorer/index.js";
import { gaasbotPackage } from "./gaasbot/index.js";
import { greybeardPackage } from "./greybeard/index.js";
import { builderPackage } from "./builder/index.js";
import { internPackage } from "./intern/index.js";
import { migratorPackage } from "./migrator/index.js";
import { neckbeardPackage } from "./neckbeard/index.js";
import { counselPackage } from "./counsel/index.js";
import { shakespearePackage } from "./shakespeare/index.js";
import { skywalkerPackage } from "./skywalker/index.js";
import { testerPackage } from "./tester/index.js";
import { testsmithPackage } from "./testsmith/index.js";
import { gauntletPackage } from "./gauntlet/index.js";
import { proberPackage } from "./prober/index.js";
import { wardenPackage } from "./warden/index.js";
import { randPackage } from "./rand/package.js";
import { bruckheimerPackage } from "./bruckheimer/package.js";
import { criticPackage } from "./critic/package.js";
import { draperPackage } from "./draper/package.js";
import { emilPackage } from "./emil/package.js";
import { explorerPackage } from "./explorer/package.js";
import { gaasbotPackage } from "./gaasbot/package.js";
import { greybeardPackage } from "./greybeard/package.js";
import { builderPackage } from "./builder/package.js";
import { internPackage } from "./intern/package.js";
import { migratorPackage } from "./migrator/package.js";
import { neckbeardPackage } from "./neckbeard/package.js";
import { counselPackage } from "./counsel/package.js";
import { shakespearePackage } from "./shakespeare/package.js";
import { skywalkerPackage } from "./skywalker/package.js";
import { testerPackage } from "./tester/package.js";
import { testsmithPackage } from "./testsmith/package.js";
import { gauntletPackage } from "./gauntlet/package.js";
import { proberPackage } from "./prober/package.js";
import { wardenPackage } from "./warden/package.js";
import { formatDirectorSystemPrompt } from "./identity.js";
import {
DIRECTOR_IDS,
Expand Down
1 change: 0 additions & 1 deletion src/agent/directors/shakespeare/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/skywalker/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/tester/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/testsmith/index.ts

This file was deleted.

1 change: 0 additions & 1 deletion src/agent/directors/warden/index.ts

This file was deleted.

42 changes: 42 additions & 0 deletions src/agent/lexical-rank.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// One shared dependency-free lexical ranker behind tool_search,
// search_agents, and skill_search. Exact name-token hits weigh most, then
// blob-token hits, then raw-substring matches (so "linear" finds
// mcp__linear__* even though it is not a whole token there).

export function tokenizeLexical(text: string): string[] {
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}

export interface LexicalFields {
readonly name: string;
readonly blob: string;
readonly nameTokens: readonly string[];
readonly blobTokens: ReadonlySet<string>;
}

export function lexicalFields(name: string, blob: string): LexicalFields {
return {
name,
blob,
nameTokens: tokenizeLexical(name),
blobTokens: new Set(tokenizeLexical(blob)),
};
}

export function scoreLexical(
fields: LexicalFields,
queryTokens: readonly string[],
rawQuery: string,
): number {
const name = fields.name.toLowerCase();
const blob = fields.blob.toLowerCase();
let total = 0;
for (const token of queryTokens) {
if (fields.nameTokens.includes(token)) total += 3;
else if (fields.blobTokens.has(token)) total += 1;
else if (name.includes(token)) total += 0.75;
else if (blob.includes(token)) total += 0.25;
}
if (name.includes(rawQuery)) total += 1;
return total;
}
27 changes: 11 additions & 16 deletions src/agent/skill-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import type { ToolDefinition } from "@intx/types/runtime";
import { type } from "arktype";

import type { SkillSummary } from "../extensions/skills.js";
import {
lexicalFields,
scoreLexical,
tokenizeLexical,
} from "./lexical-rank.js";

// Catalog lookup for skills. Names live in the system prompt; this tool returns
// matching name + description so the model can choose. Bodies load via use_skill.
Expand Down Expand Up @@ -33,10 +38,6 @@ export interface CreateSkillSearchToolArgs {
allowedNames?: readonly string[];
}

function tokenize(text: string): string[] {
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}

function visibleSkills(
skills: readonly SkillSummary[],
allowedNames: readonly string[] | undefined,
Expand All @@ -51,17 +52,11 @@ function scoreSkill(
queryTokens: string[],
rawQuery: string,
): number {
const nameTokens = tokenize(skill.name);
const descTokens = new Set(tokenize(skill.description));
let total = 0;
for (const token of queryTokens) {
if (nameTokens.includes(token)) total += 3;
else if (descTokens.has(token)) total += 1;
else if (skill.name.toLowerCase().includes(token)) total += 0.75;
else if (skill.description.toLowerCase().includes(token)) total += 0.25;
}
if (skill.name.toLowerCase().includes(rawQuery)) total += 1;
return total;
return scoreLexical(
lexicalFields(skill.name, skill.description),
queryTokens,
rawQuery,
);
}

const SkillSearchArgs = type({ query: "string" });
Expand All @@ -83,7 +78,7 @@ export function createSkillSearchTool(
if (query.length === 0)
return "Error: skill_search requires a non-empty query.";
const rawQuery = query.toLowerCase();
const queryTokens = tokenize(query);
const queryTokens = tokenizeLexical(query);
if (queryTokens.length === 0) {
return `No skills matched "${query}". Try different keywords describing the capability.`;
}
Expand Down
31 changes: 12 additions & 19 deletions src/agent/tool-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import type { AgentTool } from "@intx/agent";
import type { ToolDefinition } from "@intx/types/runtime";
import { type } from "arktype";

import {
lexicalFields,
scoreLexical,
tokenizeLexical,
} from "./lexical-rank.js";
import type { SessionMode } from "../config/session-mode.js";
import { sessionModeEnablesSubAgents } from "../config/session-mode.js";

Expand Down Expand Up @@ -229,10 +234,6 @@ export interface ToolIndex {
search(query: string, limit?: number): string[];
}

function tokenize(text: string): string[] {
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
}

// A dependency-free lexical ranker over each tool's name + description. Exact name
// token hits weigh most, then description token hits, then raw-substring matches
// (so "linear" finds mcp__linear__* even though it is not a whole token there).
Expand All @@ -247,25 +248,17 @@ export function createToolIndex(
def: ToolDefinition,
queryTokens: string[],
rawQuery: string,
): number => {
const nameTokens = tokenize(def.name);
const descTokens = new Set(tokenize(def.description ?? ""));
let total = 0;
for (const token of queryTokens) {
if (nameTokens.includes(token)) total += 3;
else if (descTokens.has(token)) total += 1;
else if (def.name.toLowerCase().includes(token)) total += 0.75;
else if ((def.description ?? "").toLowerCase().includes(token))
total += 0.25;
}
if (def.name.toLowerCase().includes(rawQuery)) total += 1;
return total;
};
): number =>
scoreLexical(
lexicalFields(def.name, def.description ?? ""),
queryTokens,
rawQuery,
);

return {
search(query: string, limit = 8): string[] {
const rawQuery = query.toLowerCase().trim();
const queryTokens = tokenize(query);
const queryTokens = tokenizeLexical(query);
if (queryTokens.length === 0) return [];
return getDefs()
.filter((def) => !advertisedNames.includes(def.name))
Expand Down
2 changes: 1 addition & 1 deletion src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
submitOutputDefinition,
} from "../agent/director.js";
import { manageTasksDefinition } from "./tasks.js";
import { validateView } from "../tui/view/index.js";
import { validateView } from "../tui/view/validate.js";
import { SETTINGS_DIR_NAME } from "../branding.js";
import {
advertiseShellGuardTimeout,
Expand Down
4 changes: 2 additions & 2 deletions src/tui/agent-ask-wake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { classifySubmission, createSubmitHandler } from "./runner/submit.js";
import {
createDeliveryGeneration,
createLeftoverSend,
createSessionOperationQueue,
routeQueuedDelivery,
} from "./queued-delivery.js";
import { createSessionOperationQueue } from "./session-operation-queue.js";
} from "./delivery-queue.js";
import { ingestOperatorPrompt } from "./prompt-attachments.js";
import {
armFeedbackCapture,
Expand Down
2 changes: 1 addition & 1 deletion src/tui/collapse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { describe, expect, test } from "bun:test";
import { defined } from "../../tests/helpers/defined.js";
import { toolCallRow } from "./diff";
import { resolveSideMargin } from "./geometry/margins";
import { resolveSideMargin } from "./geometry/zones";
import { withTestRenderer } from "./harness";
import { pushToolCall, pushToolResult } from "./tool-rows";
import {
Expand Down
Loading
Loading