diff --git a/CLAUDE.md b/CLAUDE.md index 5e762a33..e0989688 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,7 @@ Long-form docs for specific features. Read the relevant file before making chang | Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` | | Calls (video mode) — one hands-free call engine with four presets (voice / video / share screen / practice coaching), device-derived surfaces (full-screen ⇄ floating popout), frame pipeline, prompts catalog | `apps/x/VIDEO_MODE.md` | | Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` | -| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `AGENTS.md` (repo root), `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` | +| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` | ## Common Tasks diff --git a/apps/x/VIDEO_MODE.md b/apps/x/VIDEO_MODE.md index 653521cb..15549906 100644 --- a/apps/x/VIDEO_MODE.md +++ b/apps/x/VIDEO_MODE.md @@ -108,7 +108,7 @@ is live) to the outgoing message as `UserImagePart`s and sets (`data`, `mediaType`), `source: 'camera' | 'screen'`, `capturedAt`. Unlike file attachments (path references read via the `LLMParse` tool), image parts go to the model as real multimodal image parts. -- `packages/core/src/agents/runtime.ts` `convertFromMessages` (~line 1013): +- `packages/core/src/agents/message-encoding.ts` `convertFromMessages`: emits a context line (frame counts + time span), then labeled groups — a `"Webcam frames (oldest to newest):"` text part before camera images and a `"Screen-share frames (oldest to newest):"` text part before screen @@ -176,8 +176,8 @@ Push-to-talk is disabled while a call owns the mic. | Prompt | Where | |--------|-------| -| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/agents/runtime.ts:386` (`composeSystemInstructions`, gated on `videoMode`) | -| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `composeSystemInstructions`, gated on `coachMode` (directly after the video section) | +| `# Video Mode (Live Camera)` system section — how to use webcam frames, coaching guidance, screen-share rules ("treat the screen as the primary subject", "last screen frame is current"), etiquette (never comment on appearance) | `packages/core/src/application/assistant/capabilities/modes.ts` (the `VIDEO_MODE` fragment of the `video-mode` capability, composed by `agents/compose-instructions.ts`) | +| `# Practice Session (Coach Mode)` system section — coaching persona: specific/actionable feedback after each take, one-sentence interjections mid-flow, structured debrief on wrap-up | `capabilities/modes.ts` (the `COACH_MODE` fragment, directly after the video capability) | | "Driving the app" paragraph in the video-mode section — on calls, prefer app-navigation read-view/open-item (show while telling) over describing or squinting at frames | same `# Video Mode` section; full action docs in the `app-navigation` skill (`application/assistant/skills/app-navigation/skill.ts`) | | Per-message frame context line `[Video mode: N live webcam frames … and M frames of the user's shared screen …]` + group labels | `packages/core/src/agents/runtime.ts` (`convertFromMessages`) | | `videoMode` / `coachMode` composition overrides (session-sticky; flips bust prefix cache) | `packages/core/src/turns/bridges/real-agent-resolver.ts` (`CompositionOverrides`); set from `App.tsx` `sendConfig` | diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index e47d7593..59cb1392 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -31,7 +31,7 @@ import { ServiceEvent } from '@x/shared/dist/service-events.js'; import type { SessionBusEvent } from '@x/shared/dist/sessions.js'; import { isDurableTurnEvent } from '@x/shared/dist/turns.js'; import type { ISessions, EmitterSessionBus } from '@x/core/dist/sessions/index.js'; -import type { TurnEventHub } from '@x/core/dist/turns/event-hub.js'; +import type { ITurnEventBus } from '@x/core/dist/turns/event-hub.js'; import container from '@x/core/dist/di/container.js'; import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js'; @@ -728,7 +728,7 @@ export function startTurnEventsWatcher(): void { if (turnEventsWatcher) { return; } - const hub = container.resolve('turnEventBus'); + const hub = container.resolve('turnEventBus'); turnEventsWatcher = hub.subscribeAll((event) => { if (isDurableTurnEvent(event.event)) { broadcastToWindows('turns:events', event); diff --git a/apps/x/packages/core/src/agents/message-encoding.ts b/apps/x/packages/core/src/agents/message-encoding.ts new file mode 100644 index 00000000..6c1e49aa --- /dev/null +++ b/apps/x/packages/core/src/agents/message-encoding.ts @@ -0,0 +1,187 @@ +// Structural→wire message encoding: converts stored conversation messages +// into AI SDK ModelMessages (user-context weaving, attachment rendering, +// tool-result enveloping). Deterministic per message, so composed requests +// stay byte-stable. Shared by both engines — extracted from the legacy +// engine file so the turn-runtime bridges no longer depend on it. + +import { ModelMessage } from "ai"; +import { Message, UserMessageContext } from "@x/shared/dist/message.js"; +import { z } from "zod"; + +function formatUserMessageContextForLlm(userMessageContext: z.infer): string { + const sections: string[] = []; + + if (userMessageContext.currentDateTime) { + sections.push(`Current date and time: ${userMessageContext.currentDateTime}`); + } + + if (userMessageContext.middlePane) { + if (userMessageContext.middlePane.kind === 'empty') { + sections.push(`Middle pane:\nState: empty`); + } else if (userMessageContext.middlePane.kind === 'note') { + sections.push(`Middle pane:\nState: note\nPath: ${userMessageContext.middlePane.path}\n\nContent:\n\`\`\`\n${userMessageContext.middlePane.content}\n\`\`\``); + } else { + sections.push(`Middle pane:\nState: browser\nURL: ${userMessageContext.middlePane.url}\nTitle: ${userMessageContext.middlePane.title}`); + } + } + + if (sections.length === 0) { + return ''; + } + + return `# User Context +${sections.join('\n\n')} + +# User Message +`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function convertFromMessages(messages: z.infer[]): ModelMessage[] { + const result: ModelMessage[] = []; + for (const msg of messages) { + const { providerOptions } = msg; + switch (msg.role) { + case "assistant": + if (typeof msg.content === 'string') { + result.push({ + role: "assistant", + content: msg.content, + providerOptions, + }); + } else { + result.push({ + role: "assistant", + content: msg.content.map(part => { + switch (part.type) { + case 'text': + return part; + case 'reasoning': + return part; + case 'tool-call': + return { + type: 'tool-call', + toolCallId: part.toolCallId, + toolName: part.toolName, + input: part.arguments, + providerOptions: part.providerOptions, + }; + } + }), + providerOptions, + }); + } + break; + case "system": + result.push({ + role: "system", + content: msg.content, + providerOptions, + }); + break; + case "user": { + const userMessageContextPrefix = msg.userMessageContext ? formatUserMessageContextForLlm(msg.userMessageContext) : ''; + if (typeof msg.content === 'string') { + // Legacy string — pass through unchanged + result.push({ + role: "user", + content: `${userMessageContextPrefix}${msg.content}`, + providerOptions, + }); + } else { + // New content parts array — collapse text/attachments to text + // for the LLM; inline image parts (video-mode webcam and + // screen-share frames) are passed through as real multimodal + // image parts, grouped under labeled text headers so the + // model knows which images show the user vs their screen. + const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : []; + const attachmentLines: string[] = []; + type EncodedImagePart = { type: "image"; image: string; mediaType: string }; + const cameraParts: EncodedImagePart[] = []; + const screenParts: EncodedImagePart[] = []; + const frameTimes: string[] = []; + + for (const part of msg.content) { + if (part.type === "attachment") { + const sizeStr = part.size ? `, ${formatBytes(part.size)}` : ''; + const lineStr = part.lineNumber ? ` (line ${part.lineNumber})` : ''; + attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`); + } else if (part.type === "image") { + const target = part.source === "screen" ? screenParts : cameraParts; + target.push({ type: "image", image: part.data, mediaType: part.mediaType }); + if (part.capturedAt) frameTimes.push(part.capturedAt); + } else { + textSegments.push(part.text); + } + } + + if (attachmentLines.length > 0) { + if (userMessageContextPrefix) { + textSegments.push("User has attached the following files:", ...attachmentLines, ""); + } else { + textSegments.unshift("User has attached the following files:", ...attachmentLines, ""); + } + } + + const imageCount = cameraParts.length + screenParts.length; + if (imageCount > 0) { + const span = frameTimes.length >= 2 + ? ` spanning ${frameTimes[0]} to ${frameTimes[frameTimes.length - 1]}` + : frameTimes.length === 1 + ? ` captured at ${frameTimes[0]}` + : ''; + const kinds: string[] = []; + if (cameraParts.length > 0) kinds.push(`${cameraParts.length} live webcam frame${cameraParts.length === 1 ? '' : 's'} of the user`); + if (screenParts.length > 0) kinds.push(`${screenParts.length} frame${screenParts.length === 1 ? '' : 's'} of the user's shared screen`); + textSegments.push(`[Video mode: ${kinds.join(' and ')} attached below, each group oldest to newest,${span ? span + ',' : ''} recorded while they composed this message.]`); + const content: Array<{ type: "text"; text: string } | EncodedImagePart> = [ + { type: "text", text: textSegments.join("\n") }, + ]; + if (cameraParts.length > 0) { + content.push({ type: "text", text: "Webcam frames (oldest to newest):" }, ...cameraParts); + } + if (screenParts.length > 0) { + content.push({ type: "text", text: "Screen-share frames (oldest to newest):" }, ...screenParts); + } + result.push({ + role: "user", + content, + providerOptions, + }); + } else { + result.push({ + role: "user", + content: textSegments.join("\n"), + providerOptions, + }); + } + } + break; + } + case "tool": + result.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: msg.toolCallId, + toolName: msg.toolName, + output: { + type: "text", + value: msg.content, + }, + }, + ], + providerOptions, + }); + break; + } + } + // doing this because: https://github.com/OpenRouterTeam/ai-sdk-provider/issues/262 + return JSON.parse(JSON.stringify(result)); +} diff --git a/apps/x/packages/core/src/agents/permission-metadata.ts b/apps/x/packages/core/src/agents/permission-metadata.ts new file mode 100644 index 00000000..6f44970f --- /dev/null +++ b/apps/x/packages/core/src/agents/permission-metadata.ts @@ -0,0 +1,139 @@ +// Deterministic tool-permission metadata: decides whether a builtin tool +// call needs human approval (command allowlist, workspace file boundaries, +// file-access grants). Shared by both engines — extracted from the legacy +// engine file so the turn-runtime bridges no longer depend on it. + +import path from "path"; +import { WorkDir } from "../config/config.js"; +import { ToolAttachment } from "@x/shared/dist/agent.js"; +import { ToolCallPart } from "@x/shared/dist/message.js"; +import { z } from "zod"; +import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; +import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js"; +import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js"; +import { resolveFilePathForPermission } from "../filesystem/files.js"; + +type ToolPermissionMetadataValue = z.infer; + +function isPathInside(parent: string, child: string): boolean { + const relative = path.relative(parent, child); + return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function fileGrantCoversPath(grant: FileAccessGrant, operation: FileAccessOperation, resolvedPath: string): boolean { + return grant.operation === operation && isPathInside(path.resolve(grant.pathPrefix), path.resolve(resolvedPath)); +} + +function commonPathPrefix(paths: string[]): string { + if (!paths.length) return path.resolve(WorkDir); + const split = paths.map(p => path.resolve(p).split(path.sep).filter(Boolean)); + const first = split[0]; + const common: string[] = []; + for (let i = 0; i < first.length; i++) { + if (split.every(parts => parts[i] === first[i])) { + common.push(first[i]); + } else { + break; + } + } + const prefix = `${path.sep}${common.join(path.sep)}`; + return prefix === path.sep ? prefix : path.resolve(prefix); +} + +function grantPrefixForTool(toolName: string, resolvedPaths: string[]): string { + if (toolName === 'file-list' || toolName === 'file-glob' || toolName === 'file-grep' || toolName === 'file-mkdir') { + return commonPathPrefix(resolvedPaths); + } + const parentPaths = resolvedPaths.map(p => path.dirname(p)); + return commonPathPrefix(parentPaths); +} + +function filePermissionTargets(toolName: string, args: Record): { operation: FileAccessOperation; paths: string[] } | null { + const pathArg = typeof args.path === 'string' ? args.path : undefined; + switch (toolName) { + case 'file-readText': + case 'parseFile': + case 'LLMParse': + case 'file-exists': + case 'file-stat': + return pathArg ? { operation: 'read', paths: [pathArg] } : null; + case 'file-list': + return pathArg ? { operation: 'list', paths: [pathArg || '.'] } : null; + case 'file-glob': + return { operation: 'search', paths: [typeof args.cwd === 'string' && args.cwd ? args.cwd : '.'] }; + case 'file-grep': + return { operation: 'search', paths: [typeof args.searchPath === 'string' && args.searchPath ? args.searchPath : '.'] }; + case 'file-writeText': + case 'file-editText': + case 'file-mkdir': + return pathArg ? { operation: 'write', paths: [pathArg] } : null; + case 'file-copy': + case 'file-rename': { + const from = typeof args.from === 'string' ? args.from : undefined; + const to = typeof args.to === 'string' ? args.to : undefined; + return from && to ? { operation: 'write', paths: [from, to] } : null; + } + case 'file-remove': + return pathArg ? { operation: 'delete', paths: [pathArg] } : null; + default: + return null; + } +} + +export async function getToolPermissionMetadata( + toolCall: z.infer, + underlyingTool: z.infer, + sessionAllowedCommands: Set, + sessionAllowedFileAccess: FileAccessGrant[], +): Promise { + if (underlyingTool.type !== 'builtin') { + return null; + } + + if (underlyingTool.name === 'executeCommand') { + const args = toolCall.arguments; + if (!args || typeof args !== 'object' || !('command' in args)) { + return null; + } + const command = String((args as { command: unknown }).command); + if (!isBlocked(command, sessionAllowedCommands)) { + return null; + } + return { + kind: 'command', + commandNames: extractCommandNames(command), + }; + } + + const args = toolCall.arguments && typeof toolCall.arguments === 'object' + ? toolCall.arguments as Record + : {}; + const targets = filePermissionTargets(underlyingTool.name, args); + if (!targets) { + return null; + } + + const resolvedTargets = await Promise.all(targets.paths.map(p => resolveFilePathForPermission(p))); + const outsideWorkspacePaths = resolvedTargets + .filter(target => !target.isInsideWorkspace) + .map(target => target.canonicalPath); + if (!outsideWorkspacePaths.length) { + return null; + } + + const persistentGrants = getFileAccessAllowList(); + const allGrants = [...persistentGrants, ...sessionAllowedFileAccess]; + const uncovered = outsideWorkspacePaths.filter(resolvedPath => + !allGrants.some(grant => fileGrantCoversPath(grant, targets.operation, resolvedPath)) + ); + if (!uncovered.length) { + return null; + } + + return { + kind: 'file', + operation: targets.operation, + paths: uncovered, + pathPrefix: grantPrefixForTool(underlyingTool.name, uncovered), + }; +} diff --git a/apps/x/packages/core/src/agents/registry.ts b/apps/x/packages/core/src/agents/registry.ts index f09a5034..5522f274 100644 --- a/apps/x/packages/core/src/agents/registry.ts +++ b/apps/x/packages/core/src/agents/registry.ts @@ -23,6 +23,10 @@ export interface AgentTraits { // Receives workspace context — agent notes and the user work directory — // composed into its system prompt. workspaceContext?: boolean; + // Session-loaded skills (activeSkills) re-attach their tools on later + // turns. Distinct from workspaceContext: a trait per concern, so neither + // silently inherits the other's meaning. + skillCarryForward?: boolean; } interface BuiltinAgentDefinition { @@ -51,7 +55,7 @@ function promptFileAgent(id: string, getRaw: () => string): BuiltinAgentDefiniti // definition object. const COPILOT: BuiltinAgentDefinition = { build: buildCopilotAgent, - traits: { workspaceContext: true }, + traits: { workspaceContext: true, skillCarryForward: true }, }; const builtinAgents: Record = { @@ -71,15 +75,26 @@ export function builtinAgentIds(): string[] { return Object.keys(builtinAgents); } -// Trait lookup for assembly decisions. Unknown/user agents have no traits. -export function hasWorkspaceContext(agentId: string | null | undefined): boolean { +// Trait lookups for assembly decisions. Unknown/user agents have no traits. +function hasTrait( + agentId: string | null | undefined, + trait: keyof AgentTraits, +): boolean { return ( agentId != null && Object.hasOwn(builtinAgents, agentId) && - builtinAgents[agentId].traits?.workspaceContext === true + builtinAgents[agentId].traits?.[trait] === true ); } +export function hasWorkspaceContext(agentId: string | null | undefined): boolean { + return hasTrait(agentId, "workspaceContext"); +} + +export function carriesSkillsForward(agentId: string | null | undefined): boolean { + return hasTrait(agentId, "skillCarryForward"); +} + export async function loadAgent(id: string): Promise> { // Object.hasOwn: a plain lookup would traverse Object.prototype, so a // user agent named "constructor"/"toString" would hit an inherited diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index d4d9b9c3..2241197e 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -1,21 +1,20 @@ -import { jsonSchema, ModelMessage } from "ai"; -import fs from "fs"; -import path from "path"; -import { WorkDir } from "../config/config.js"; +import { jsonSchema } from "ai"; import { Agent, ToolAttachment } from "@x/shared/dist/agent.js"; -import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessageContext } from "@x/shared/dist/message.js"; +import { AssistantContentPart, AssistantMessage, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessageContext } from "@x/shared/dist/message.js"; import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"; import { z } from "zod"; import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js"; import { execTool } from "../application/lib/exec-tool.js"; import { TOOL_ADDITIONS_KEY } from "../application/lib/tool-additions.js"; -import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js"; +import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js"; import { BuiltinTools } from "../application/lib/builtin-tools.js"; import { hasWorkspaceContext, loadAgent } from "./registry.js"; +import { composeSystemInstructions } from "./compose-instructions.js"; +import { convertFromMessages } from "./message-encoding.js"; +import { getToolPermissionMetadata } from "./permission-metadata.js"; import { loadWorkspaceContext } from "./workspace-context.js"; -import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js"; -import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js"; -import { resolveFilePathForPermission } from "../filesystem/files.js"; +import { extractCommandNames } from "../application/lib/command-executor.js"; +import { type FileAccessGrant } from "../config/security.js"; import { notifyIfEnabled } from "../application/notification/notifier.js"; import { IModelConfigRepo } from "../models/repo.js"; import { createLanguageModel } from "../models/models.js"; @@ -26,137 +25,12 @@ import { IBus } from "../application/lib/bus.js"; import { IMessageQueue, type MiddlePaneContext } from "../application/lib/message-queue.js"; import { IRunsRepo } from "../runs/repo.js"; import { IRunsLock } from "../runs/lock.js"; -import { IAbortRegistry } from "../runs/abort-registry.js"; +import { IAbortRegistry } from "../turns/abort-registry.js"; import { PrefixLogger } from "@x/shared"; import { captureLlmUsage } from "../analytics/usage.js"; import { enterUseCase, withUseCase, type UseCase } from "../analytics/use_case.js"; import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js"; -type ToolPermissionMetadataValue = z.infer; - -function isPathInside(parent: string, child: string): boolean { - const relative = path.relative(parent, child); - return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)); -} - -function fileGrantCoversPath(grant: FileAccessGrant, operation: FileAccessOperation, resolvedPath: string): boolean { - return grant.operation === operation && isPathInside(path.resolve(grant.pathPrefix), path.resolve(resolvedPath)); -} - -function commonPathPrefix(paths: string[]): string { - if (!paths.length) return path.resolve(WorkDir); - const split = paths.map(p => path.resolve(p).split(path.sep).filter(Boolean)); - const first = split[0]; - const common: string[] = []; - for (let i = 0; i < first.length; i++) { - if (split.every(parts => parts[i] === first[i])) { - common.push(first[i]); - } else { - break; - } - } - const prefix = `${path.sep}${common.join(path.sep)}`; - return prefix === path.sep ? prefix : path.resolve(prefix); -} - -function grantPrefixForTool(toolName: string, resolvedPaths: string[]): string { - if (toolName === 'file-list' || toolName === 'file-glob' || toolName === 'file-grep' || toolName === 'file-mkdir') { - return commonPathPrefix(resolvedPaths); - } - const parentPaths = resolvedPaths.map(p => path.dirname(p)); - return commonPathPrefix(parentPaths); -} - -function filePermissionTargets(toolName: string, args: Record): { operation: FileAccessOperation; paths: string[] } | null { - const pathArg = typeof args.path === 'string' ? args.path : undefined; - switch (toolName) { - case 'file-readText': - case 'parseFile': - case 'LLMParse': - case 'file-exists': - case 'file-stat': - return pathArg ? { operation: 'read', paths: [pathArg] } : null; - case 'file-list': - return pathArg ? { operation: 'list', paths: [pathArg || '.'] } : null; - case 'file-glob': - return { operation: 'search', paths: [typeof args.cwd === 'string' && args.cwd ? args.cwd : '.'] }; - case 'file-grep': - return { operation: 'search', paths: [typeof args.searchPath === 'string' && args.searchPath ? args.searchPath : '.'] }; - case 'file-writeText': - case 'file-editText': - case 'file-mkdir': - return pathArg ? { operation: 'write', paths: [pathArg] } : null; - case 'file-copy': - case 'file-rename': { - const from = typeof args.from === 'string' ? args.from : undefined; - const to = typeof args.to === 'string' ? args.to : undefined; - return from && to ? { operation: 'write', paths: [from, to] } : null; - } - case 'file-remove': - return pathArg ? { operation: 'delete', paths: [pathArg] } : null; - default: - return null; - } -} - -export async function getToolPermissionMetadata( - toolCall: z.infer, - underlyingTool: z.infer, - sessionAllowedCommands: Set, - sessionAllowedFileAccess: FileAccessGrant[], -): Promise { - if (underlyingTool.type !== 'builtin') { - return null; - } - - if (underlyingTool.name === 'executeCommand') { - const args = toolCall.arguments; - if (!args || typeof args !== 'object' || !('command' in args)) { - return null; - } - const command = String((args as { command: unknown }).command); - if (!isBlocked(command, sessionAllowedCommands)) { - return null; - } - return { - kind: 'command', - commandNames: extractCommandNames(command), - }; - } - - const args = toolCall.arguments && typeof toolCall.arguments === 'object' - ? toolCall.arguments as Record - : {}; - const targets = filePermissionTargets(underlyingTool.name, args); - if (!targets) { - return null; - } - - const resolvedTargets = await Promise.all(targets.paths.map(p => resolveFilePathForPermission(p))); - const outsideWorkspacePaths = resolvedTargets - .filter(target => !target.isInsideWorkspace) - .map(target => target.canonicalPath); - if (!outsideWorkspacePaths.length) { - return null; - } - - const persistentGrants = getFileAccessAllowList(); - const allGrants = [...persistentGrants, ...sessionAllowedFileAccess]; - const uncovered = outsideWorkspacePaths.filter(resolvedPath => - !allGrants.some(grant => fileGrantCoversPath(grant, targets.operation, resolvedPath)) - ); - if (!uncovered.length) { - return null; - } - - return { - kind: 'file', - operation: targets.operation, - paths: uncovered, - pathPrefix: grantPrefixForTool(underlyingTool.name, uncovered), - }; -} - function formatCurrentDateTime(now: Date): string { return now.toLocaleString('en-US', { weekday: 'long', @@ -202,35 +76,6 @@ function buildUserMessageContext({ }; } -function formatUserMessageContextForLlm(userMessageContext: z.infer): string { - const sections: string[] = []; - - if (userMessageContext.currentDateTime) { - sections.push(`Current date and time: ${userMessageContext.currentDateTime}`); - } - - if (userMessageContext.middlePane) { - if (userMessageContext.middlePane.kind === 'empty') { - sections.push(`Middle pane:\nState: empty`); - } else if (userMessageContext.middlePane.kind === 'note') { - sections.push(`Middle pane:\nState: note\nPath: ${userMessageContext.middlePane.path}\n\nContent:\n\`\`\`\n${userMessageContext.middlePane.content}\n\`\`\``); - } else { - sections.push(`Middle pane:\nState: browser\nURL: ${userMessageContext.middlePane.url}\nTitle: ${userMessageContext.middlePane.title}`); - } - } - - if (sections.length === 0) { - return ''; - } - - return `# User Context -${sections.join('\n\n')} - -# User Message -`; -} - -import { composeSystemInstructions } from "./compose-instructions.js"; export interface IAgentRuntime { trigger(runId: string): Promise; @@ -406,7 +251,7 @@ export class AgentRuntime implements IAgentRuntime { } } -export async function mapAgentTool(t: z.infer): Promise { +async function mapAgentTool(t: z.infer): Promise { switch (t.type) { case "mcp": return tool({ @@ -449,38 +294,8 @@ export async function mapAgentTool(t: z.infer): Promise) { - if (event.type !== "llm-stream-event") { - this.fileHandle.write(JSON.stringify(event) + "\n"); - } - } - - close() { - this.fileHandle.close(); - } -} - -export class StreamStepMessageBuilder { +class StreamStepMessageBuilder { private parts: z.infer[] = []; private textBuffer: string = ""; private reasoningBuffer: string = ""; @@ -569,156 +384,6 @@ function formatLlmStreamError(rawError: unknown): string { return lines.length ? lines.join("\n") : "Model stream error"; } -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -export function convertFromMessages(messages: z.infer[]): ModelMessage[] { - const result: ModelMessage[] = []; - for (const msg of messages) { - const { providerOptions } = msg; - switch (msg.role) { - case "assistant": - if (typeof msg.content === 'string') { - result.push({ - role: "assistant", - content: msg.content, - providerOptions, - }); - } else { - result.push({ - role: "assistant", - content: msg.content.map(part => { - switch (part.type) { - case 'text': - return part; - case 'reasoning': - return part; - case 'tool-call': - return { - type: 'tool-call', - toolCallId: part.toolCallId, - toolName: part.toolName, - input: part.arguments, - providerOptions: part.providerOptions, - }; - } - }), - providerOptions, - }); - } - break; - case "system": - result.push({ - role: "system", - content: msg.content, - providerOptions, - }); - break; - case "user": { - const userMessageContextPrefix = msg.userMessageContext ? formatUserMessageContextForLlm(msg.userMessageContext) : ''; - if (typeof msg.content === 'string') { - // Legacy string — pass through unchanged - result.push({ - role: "user", - content: `${userMessageContextPrefix}${msg.content}`, - providerOptions, - }); - } else { - // New content parts array — collapse text/attachments to text - // for the LLM; inline image parts (video-mode webcam and - // screen-share frames) are passed through as real multimodal - // image parts, grouped under labeled text headers so the - // model knows which images show the user vs their screen. - const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : []; - const attachmentLines: string[] = []; - type EncodedImagePart = { type: "image"; image: string; mediaType: string }; - const cameraParts: EncodedImagePart[] = []; - const screenParts: EncodedImagePart[] = []; - const frameTimes: string[] = []; - - for (const part of msg.content) { - if (part.type === "attachment") { - const sizeStr = part.size ? `, ${formatBytes(part.size)}` : ''; - const lineStr = part.lineNumber ? ` (line ${part.lineNumber})` : ''; - attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`); - } else if (part.type === "image") { - const target = part.source === "screen" ? screenParts : cameraParts; - target.push({ type: "image", image: part.data, mediaType: part.mediaType }); - if (part.capturedAt) frameTimes.push(part.capturedAt); - } else { - textSegments.push(part.text); - } - } - - if (attachmentLines.length > 0) { - if (userMessageContextPrefix) { - textSegments.push("User has attached the following files:", ...attachmentLines, ""); - } else { - textSegments.unshift("User has attached the following files:", ...attachmentLines, ""); - } - } - - const imageCount = cameraParts.length + screenParts.length; - if (imageCount > 0) { - const span = frameTimes.length >= 2 - ? ` spanning ${frameTimes[0]} to ${frameTimes[frameTimes.length - 1]}` - : frameTimes.length === 1 - ? ` captured at ${frameTimes[0]}` - : ''; - const kinds: string[] = []; - if (cameraParts.length > 0) kinds.push(`${cameraParts.length} live webcam frame${cameraParts.length === 1 ? '' : 's'} of the user`); - if (screenParts.length > 0) kinds.push(`${screenParts.length} frame${screenParts.length === 1 ? '' : 's'} of the user's shared screen`); - textSegments.push(`[Video mode: ${kinds.join(' and ')} attached below, each group oldest to newest,${span ? span + ',' : ''} recorded while they composed this message.]`); - const content: Array<{ type: "text"; text: string } | EncodedImagePart> = [ - { type: "text", text: textSegments.join("\n") }, - ]; - if (cameraParts.length > 0) { - content.push({ type: "text", text: "Webcam frames (oldest to newest):" }, ...cameraParts); - } - if (screenParts.length > 0) { - content.push({ type: "text", text: "Screen-share frames (oldest to newest):" }, ...screenParts); - } - result.push({ - role: "user", - content, - providerOptions, - }); - } else { - result.push({ - role: "user", - content: textSegments.join("\n"), - providerOptions, - }); - } - } - break; - } - case "tool": - result.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: msg.toolCallId, - toolName: msg.toolName, - output: { - type: "text", - value: msg.content, - }, - }, - ], - providerOptions, - }); - break; - } - } - // doing this because: https://github.com/OpenRouterTeam/ai-sdk-provider/issues/262 - return JSON.parse(JSON.stringify(result)); -} - async function buildTools(agent: z.infer): Promise { const tools: ToolSet = {}; for (const [name, tool] of Object.entries(agent.tools ?? {})) { @@ -1582,7 +1247,3 @@ async function* streamLlm( } } } -export const MappedToolCall = z.object({ - toolCall: ToolCallPart, - agentTool: ToolAttachment, -}); diff --git a/apps/x/packages/core/src/application/assistant/connections.ts b/apps/x/packages/core/src/application/assistant/connections.ts new file mode 100644 index 00000000..aabd0c37 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/connections.ts @@ -0,0 +1,44 @@ +// Connection-state checks shared by skill availability (catalog visibility) +// and copilot prompt composition (connection-specific blocks). One source of +// truth per fact — the "is Slack connected" rule must never fork between the +// catalog and the prompt. Repos resolve lazily so this module adds no static +// DI edge; any failure reads as "not connected" (the historical default). +import { lazyResolve } from "../../di/lazy-resolve.js"; + +export async function isComposioAvailable(): Promise { + try { + const { isConfigured } = await import("../../composio/client.js"); + return await isConfigured(); + } catch { + return false; + } +} + +export async function isCodeModeAvailable(): Promise { + try { + const repo = await lazyResolve("codeModeConfigRepo"); + return (await repo.getConfig()).enabled; + } catch { + return false; + } +} + +export async function isSlackAvailable(): Promise { + try { + const repo = await lazyResolve("slackConfigRepo"); + const config = await repo.getConfig(); + return config.enabled && config.workspaces.length > 0; + } catch { + return false; + } +} + +export async function isGoogleConnected(): Promise { + try { + const repo = await lazyResolve("oauthRepo"); + const connection = await repo.read("google"); + return !!connection.tokens; + } catch { + return false; + } +} diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index d5fb4953..a2341ac5 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -1,12 +1,14 @@ -import { skillCatalog, buildAvailableSkillCatalog } from "./skills/index.js"; +import { buildAvailableSkillCatalog } from "./skills/index.js"; import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js"; +import { + isCodeModeAvailable, + isComposioAvailable, + isGoogleConnected, + isSlackAvailable, +} from "./connections.js"; import { composioAccountsRepo } from "../../composio/repo.js"; import { isConfigured as isComposioConfigured } from "../../composio/client.js"; import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js"; -import container from "../../di/container.js"; -import type { ICodeModeConfigRepo } from "../../code-mode/repo.js"; -import type { ISlackConfigRepo } from "../../slack/repo.js"; -import type { IOAuthRepo } from "../../auth/repo.js"; import { knowledgeSourcesRepo } from "../../knowledge/sources/repo.js"; const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); @@ -356,7 +358,6 @@ Never output raw file paths in plain text when they could be wrapped in a filepa } /** Keep backward-compatible export for any external consumers */ -export const CopilotInstructions = buildStaticInstructions(true, skillCatalog); let cachedInstructions: string | null = null; @@ -366,31 +367,16 @@ export function invalidateCopilotInstructionsCache(): void { export async function buildCopilotInstructions(): Promise { if (cachedInstructions !== null) return cachedInstructions; - const composioEnabled = await isComposioConfigured(); - let codeModeEnabled = false; - try { - const repo = container.resolve('codeModeConfigRepo'); - codeModeEnabled = (await repo.getConfig()).enabled; - } catch { - // repo unavailable — default to disabled - } - let slackConnected = false; + // Connection facts come from the shared checks in connections.ts — the + // same source the skill catalog's availability gating uses. + const [composioEnabled, codeModeEnabled, slackConnected, googleConnected] = + await Promise.all([ + isComposioAvailable(), + isCodeModeAvailable(), + isSlackAvailable(), + isGoogleConnected(), + ]); let slackChannelsHint = ''; - try { - const slackRepo = container.resolve('slackConfigRepo'); - const slackConfig = await slackRepo.getConfig(); - slackConnected = slackConfig.enabled && slackConfig.workspaces.length > 0; - } catch { - // repo unavailable — default to not connected - } - let googleConnected = false; - try { - const oauthRepo = container.resolve('oauthRepo'); - const googleConnection = await oauthRepo.read('google'); - googleConnected = !!googleConnection.tokens; - } catch { - // repo unavailable — default to not connected - } if (slackConnected) { try { // Surface the channels the user selected for sync so the Copilot diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index d21c7d47..4173ef62 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -5,6 +5,11 @@ import { type CapabilityDefinition, type ModelCapability, } from "../capabilities/types.js"; +import { + isCodeModeAvailable, + isComposioAvailable, + isSlackAvailable, +} from "../connections.js"; import { loadDiskSkills } from "./disk-loader.js"; import builtinToolsSkill from "./builtin-tools/skill.js"; import deletionGuardrailsSkill from "./deletion-guardrails/skill.js"; @@ -239,39 +244,8 @@ function getSkillEntries(): SkillEntry[] { } // ---- Availability checks (catalog visibility) ---- -// Connection-state gates for the catalog, evaluated per build. Repos resolve -// lazily so this module keeps no static edge into the DI graph; any failure -// reads as "not available" (the historical default). - -async function isComposioAvailable(): Promise { - try { - const { isConfigured } = await import("../../../composio/client.js"); - return await isConfigured(); - } catch { - return false; - } -} - -async function isCodeModeAvailable(): Promise { - try { - const { lazyResolve } = await import("../../../di/lazy-resolve.js"); - const repo = await lazyResolve("codeModeConfigRepo"); - return (await repo.getConfig()).enabled; - } catch { - return false; - } -} - -async function isSlackAvailable(): Promise { - try { - const { lazyResolve } = await import("../../../di/lazy-resolve.js"); - const repo = await lazyResolve("slackConfigRepo"); - const config = await repo.getConfig(); - return config.enabled && config.workspaces.length > 0; - } catch { - return false; - } -} +// One source of truth for connection state: ../connections.ts (shared with +// the copilot prompt's connection blocks in instructions.ts). // Pure availability filter over an explicit entry list (exported for tests): // entries without an availability check are kept; checks are evaluated @@ -427,10 +401,6 @@ export function refreshDiskSkills(): number { // Initial disk load at module init. refreshDiskSkills(); -// Snapshot of the catalog at module load, kept for backward-compatible -// consumers (e.g. the legacy CopilotInstructions export). Runtime consumers -// should call buildSkillCatalog() to get the live set. -export const skillCatalog = buildSkillCatalog(); export function resolveSkill(identifier: string): ResolvedSkill | null { const normalized = normalizeIdentifier(identifier); diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.test.ts b/apps/x/packages/core/src/application/lib/builtin-tools.test.ts index def0b93c..79e082d3 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.test.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.test.ts @@ -2,7 +2,7 @@ import * as os from "os"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { CodeRunEvent } from "@x/shared/dist/code-mode.js"; import container from "../../di/container.js"; -import { InMemoryAbortRegistry } from "../../runs/abort-registry.js"; +import { InMemoryAbortRegistry } from "../../turns/abort-registry.js"; import { BuiltinTools, coalesceCodeRunEvents } from "./builtin-tools.js"; import type { ToolContext } from "./exec-tool.js"; diff --git a/apps/x/packages/core/src/application/lib/exec-tool.ts b/apps/x/packages/core/src/application/lib/exec-tool.ts index 5fd2816c..49f2404a 100644 --- a/apps/x/packages/core/src/application/lib/exec-tool.ts +++ b/apps/x/packages/core/src/application/lib/exec-tool.ts @@ -3,7 +3,7 @@ import { RunEvent } from "@x/shared/dist/runs.js"; import { z } from "zod"; import { BuiltinTools } from "./builtin-tools.js"; import { executeTool } from "../../mcp/mcp.js"; -import { IAbortRegistry } from "../../runs/abort-registry.js"; +import { IAbortRegistry } from "../../turns/abort-registry.js"; /** * Context passed to every tool execution, providing abort signal and run metadata. diff --git a/apps/x/packages/core/src/channels/bridge.ts b/apps/x/packages/core/src/channels/bridge.ts index 69e84e9e..cd5d1fc9 100644 --- a/apps/x/packages/core/src/channels/bridge.ts +++ b/apps/x/packages/core/src/channels/bridge.ts @@ -4,7 +4,7 @@ import { assistantText, lastAssistantText } from "../agents/headless.js"; import { TurnInputError } from "../turns/api.js"; import { ASK_HUMAN_TOOL } from "../turns/bridges/real-agent-resolver.js"; import { TurnNotSettledError, type ISessions } from "../sessions/api.js"; -import type { TurnEventHub } from "../turns/event-hub.js"; +import type { ITurnEventBus } from "../turns/event-hub.js"; // Transport-agnostic command layer: inbound texts from a messaging channel // are parsed into commands (list / resume / new / stop / status) or forwarded @@ -136,7 +136,7 @@ export class ChannelBridge { constructor( private readonly deps: { sessions: ISessions; - turnEventBus: TurnEventHub; + turnEventBus: ITurnEventBus; listModels: () => Promise; }, ) {} diff --git a/apps/x/packages/core/src/channels/service.ts b/apps/x/packages/core/src/channels/service.ts index a0bed3f4..eb354b53 100644 --- a/apps/x/packages/core/src/channels/service.ts +++ b/apps/x/packages/core/src/channels/service.ts @@ -5,7 +5,7 @@ import type { ChannelsConfig, ChannelsStatus } from "@x/shared/dist/channels.js" import container from "../di/container.js"; import { WorkDir } from "../config/config.js"; import type { ISessions } from "../sessions/api.js"; -import type { TurnEventHub } from "../turns/event-hub.js"; +import type { ITurnEventBus } from "../turns/event-hub.js"; import { isSignedIn } from "../account/account.js"; import { listGatewayModels } from "../models/gateway.js"; import { listOnboardingModels } from "../models/models-dev.js"; @@ -98,7 +98,7 @@ function ensureBridge(): ChannelBridge { if (!bridge) { bridge = new ChannelBridge({ sessions: container.resolve("sessions"), - turnEventBus: container.resolve("turnEventBus"), + turnEventBus: container.resolve("turnEventBus"), listModels: listBridgeModels, }); } diff --git a/apps/x/packages/core/src/code-mode/sessions/service.ts b/apps/x/packages/core/src/code-mode/sessions/service.ts index 99578dab..c082dc18 100644 --- a/apps/x/packages/core/src/code-mode/sessions/service.ts +++ b/apps/x/packages/core/src/code-mode/sessions/service.ts @@ -9,7 +9,7 @@ import type { IRunsRepo } from '../../runs/repo.js'; import type { IRunsLock } from '../../runs/lock.js'; import type { IBus } from '../../application/lib/bus.js'; import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js'; -import type { IAbortRegistry } from '../../runs/abort-registry.js'; +import type { IAbortRegistry } from '../../turns/abort-registry.js'; import type { CodeModeManager } from '../acp/manager.js'; import type { CodePermissionRegistry } from '../acp/permission-registry.js'; import type { ICodeSessionsRepo } from './repo.js'; diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 2e926812..ac9d3dc2 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -14,7 +14,7 @@ import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js"; import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js"; import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js"; import { FSCodeModeConfigRepo, ICodeModeConfigRepo } from "../code-mode/repo.js"; -import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js"; +import { IAbortRegistry, InMemoryAbortRegistry } from "../turns/abort-registry.js"; import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js"; import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js"; diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index c79e8077..261122d7 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -6,7 +6,7 @@ import { IRunsRepo } from "./repo.js"; import { ICodeSessionsRepo } from "../code-mode/sessions/repo.js"; import { IAgentRuntime } from "../agents/runtime.js"; import { IBus } from "../application/lib/bus.js"; -import { IAbortRegistry } from "./abort-registry.js"; +import { IAbortRegistry } from "../turns/abort-registry.js"; import { IRunsLock } from "./lock.js"; import { forceCloseAllMcpClients } from "../mcp/mcp.js"; import { extractCommandNames } from "../application/lib/command-executor.js"; diff --git a/apps/x/packages/core/src/runs/abort-registry.ts b/apps/x/packages/core/src/turns/abort-registry.ts similarity index 100% rename from apps/x/packages/core/src/runs/abort-registry.ts rename to apps/x/packages/core/src/turns/abort-registry.ts diff --git a/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts b/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts index 635906b9..6b1c6d62 100644 --- a/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts +++ b/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts @@ -12,7 +12,7 @@ import { loadUserWorkDir, loadWorkspaceContext, } from "../../agents/workspace-context.js"; -import { hasWorkspaceContext, loadAgent } from "../../agents/registry.js"; +import { carriesSkillsForward, loadAgent } from "../../agents/registry.js"; import { BuiltinTools } from "../../application/lib/builtin-tools.js"; import { skillToolNames } from "../../application/assistant/skills/index.js"; import { ModeFlags } from "../../application/assistant/capabilities/types.js"; @@ -133,7 +133,6 @@ export class RealAgentResolver { loadNotes: this.loadNotes, loadWorkDir: this.loadWorkDir, }); - const copilotContext = hasWorkspaceContext(requested.agentId); const systemPrompt = composeSystemInstructions({ instructions: agent.instructions, ...workspace, @@ -143,7 +142,7 @@ export class RealAgentResolver { const tools = await this.resolveTools(agent, { subagent: subagent ?? false, }); - if (copilotContext && activeSkills?.length) { + if (carriesSkillsForward(requested.agentId) && activeSkills?.length) { await this.appendSkillTools(tools, activeSkills); } return ResolvedAgent.parse({ diff --git a/apps/x/packages/core/src/turns/bridges/real-model-registry.ts b/apps/x/packages/core/src/turns/bridges/real-model-registry.ts index 11897908..eb311d18 100644 --- a/apps/x/packages/core/src/turns/bridges/real-model-registry.ts +++ b/apps/x/packages/core/src/turns/bridges/real-model-registry.ts @@ -11,7 +11,7 @@ import type { z } from "zod"; import type { LlmProvider } from "@x/shared/dist/models.js"; import type { AssistantContentPart } from "@x/shared/dist/message.js"; import type { JsonValue, ModelDescriptor, TurnUsage } from "@x/shared/dist/turns.js"; -import { convertFromMessages } from "../../agents/runtime.js"; +import { convertFromMessages } from "../../agents/message-encoding.js"; import { resolveProviderConfig } from "../../models/defaults.js"; import { createProvider } from "../../models/models.js"; import { isReasoningModel } from "../../models/models-dev.js"; diff --git a/apps/x/packages/core/src/turns/bridges/real-permission-checker.ts b/apps/x/packages/core/src/turns/bridges/real-permission-checker.ts index 50fec935..ef325909 100644 --- a/apps/x/packages/core/src/turns/bridges/real-permission-checker.ts +++ b/apps/x/packages/core/src/turns/bridges/real-permission-checker.ts @@ -1,5 +1,5 @@ import type { JsonValue } from "@x/shared/dist/turns.js"; -import { getToolPermissionMetadata } from "../../agents/runtime.js"; +import { getToolPermissionMetadata } from "../../agents/permission-metadata.js"; import type { IPermissionChecker, PermissionCheckAllowed, diff --git a/apps/x/packages/core/src/turns/bridges/real-permission-classifier.ts b/apps/x/packages/core/src/turns/bridges/real-permission-classifier.ts index 1d5732d6..8abc0c03 100644 --- a/apps/x/packages/core/src/turns/bridges/real-permission-classifier.ts +++ b/apps/x/packages/core/src/turns/bridges/real-permission-classifier.ts @@ -1,5 +1,5 @@ import { ToolPermissionMetadata } from "@x/shared/dist/runs.js"; -import { convertFromMessages } from "../../agents/runtime.js"; +import { convertFromMessages } from "../../agents/message-encoding.js"; import type { UseCase } from "../../analytics/use_case.js"; import { classifyToolPermissions } from "../../security/auto-permission-classifier.js"; import type { diff --git a/apps/x/packages/core/src/turns/bridges/real-tool-registry.test.ts b/apps/x/packages/core/src/turns/bridges/real-tool-registry.test.ts index c92a4428..5238541e 100644 --- a/apps/x/packages/core/src/turns/bridges/real-tool-registry.test.ts +++ b/apps/x/packages/core/src/turns/bridges/real-tool-registry.test.ts @@ -3,7 +3,7 @@ import type { z } from "zod"; import type { ToolDescriptor } from "@x/shared/dist/turns.js"; import type { execTool } from "../../application/lib/exec-tool.js"; import type { BuiltinTools } from "../../application/lib/builtin-tools.js"; -import type { IAbortRegistry } from "../../runs/abort-registry.js"; +import type { IAbortRegistry } from "../abort-registry.js"; import { TurnDependencyError } from "../api.js"; import type { SyncRuntimeTool, ToolExecutionContext } from "../tool-registry.js"; import { RealToolRegistry } from "./real-tool-registry.js"; diff --git a/apps/x/packages/core/src/turns/bridges/real-tool-registry.ts b/apps/x/packages/core/src/turns/bridges/real-tool-registry.ts index 1f1ce9b9..7688308a 100644 --- a/apps/x/packages/core/src/turns/bridges/real-tool-registry.ts +++ b/apps/x/packages/core/src/turns/bridges/real-tool-registry.ts @@ -12,7 +12,7 @@ import { TOOL_ADDITIONS_KEY } from "../../application/lib/tool-additions.js"; import { type IAbortRegistry, InMemoryAbortRegistry, -} from "../../runs/abort-registry.js"; +} from "../abort-registry.js"; import { TurnDependencyError } from "../api.js"; import type { IToolRegistry, diff --git a/apps/x/packages/core/src/turns/event-hub.ts b/apps/x/packages/core/src/turns/event-hub.ts index ba689c5a..256443d1 100644 --- a/apps/x/packages/core/src/turns/event-hub.ts +++ b/apps/x/packages/core/src/turns/event-hub.ts @@ -8,6 +8,9 @@ import type { TurnBusEvent } from "@x/shared/dist/turns.js"; // listeners accurately reflects that no execution is known active. export interface ITurnEventBus { publish(event: TurnBusEvent): void; + // Tail one turn's events / tail everything (the IPC bridge, watchers). + subscribe(turnId: string, listener: (event: TurnBusEvent) => void): () => void; + subscribeAll(listener: (event: TurnBusEvent) => void): () => void; } type Listener = (event: TurnBusEvent) => void; diff --git a/apps/x/packages/core/src/turns/inspect-cli.ts b/apps/x/packages/core/src/turns/inspect-cli.ts index 9326a73c..4ca79d0c 100644 --- a/apps/x/packages/core/src/turns/inspect-cli.ts +++ b/apps/x/packages/core/src/turns/inspect-cli.ts @@ -27,7 +27,7 @@ import { } from "@x/shared/dist/turns.js"; import { reduceSession } from "@x/shared/dist/sessions.js"; import type { z } from "zod"; -import { convertFromMessages } from "../agents/runtime.js"; +import { convertFromMessages } from "../agents/message-encoding.js"; import { WorkDir } from "../config/config.js"; import { FSSessionRepo } from "../sessions/fs-repo.js"; import { composeModelRequest } from "./compose-model-request.js";