diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index c2e35cb2..1f5cb153 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { useCallback, useEffect, useState, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useState, useRef } from 'react' import { workspace } from '@x/shared'; import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; @@ -41,6 +41,7 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; import { Suggestions } from '@/components/ai-elements/suggestions'; import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; @@ -121,6 +122,31 @@ function SmoothStreamingMessage({ text, components }: { text: string; components return {smoothText} } +function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) { + const ref = useRef(null) + const stickToBottom = useRef(true) + + useLayoutEffect(() => { + const el = ref.current + if (el && stickToBottom.current) { + el.scrollTop = el.scrollHeight + } + }, [children]) + + const handleScroll = useCallback(() => { + const el = ref.current + if (!el) return + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + stickToBottom.current = atBottom + }, []) + + return ( +
+      {children}
+    
+ ) +} + const DEFAULT_SIDEBAR_WIDTH = 256 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ @@ -2085,6 +2111,10 @@ function App() { return next }) + if (event.toolCallId && event.toolName !== 'executeCommand') { + setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false) + } + // Handle app-navigation tool results — trigger UI side effects if (event.toolName === 'app-navigation') { const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined @@ -2096,6 +2126,23 @@ function App() { break } + case 'tool-output-stream': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if ( + isToolCall(item) + && item.id === event.toolCallId + ) { + if (!item.streamingOutput) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + } + return { ...item, streamingOutput: (item.streamingOutput ?? '') + event.output } + } + return item + })) + break + } + case 'tool-permission-request': { if (!isActiveRun) return const key = event.toolCall.toolCallId @@ -4314,7 +4361,13 @@ function App() { state={toToolState(item.status)} /> - + {item.streamingOutput ? ( + + + + ) : ( + + )} ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 6fa295b1..a7551757 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -20,6 +20,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent } import { WebSearchResult } from '@/components/ai-elements/web-search-result' import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card' import { PermissionRequest } from '@/components/ai-elements/permission-request' +import { TerminalOutput } from '@/components/terminal-output' import { AskHumanRequest } from '@/components/ai-elements/ask-human-request' import { Suggestions } from '@/components/ai-elements/suggestions' import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' @@ -59,6 +60,31 @@ const streamdownComponents = { pre: MarkdownPreOverride } // into
so typed line breaks are preserved without requiring blank lines. const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks] +function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) { + const ref = useRef(null) + const stickToBottom = useRef(true) + + useEffect(() => { + const el = ref.current + if (el && stickToBottom.current) { + el.scrollTop = el.scrollHeight + } + }, [children]) + + const handleScroll = useCallback(() => { + const el = ref.current + if (!el) return + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + stickToBottom.current = atBottom + }, []) + + return ( +
+      {children}
+    
+ ) +} + /* ─── Billing error helpers ─── */ const BILLING_ERROR_PATTERNS = [ @@ -452,7 +478,13 @@ export function ChatSidebar({ > - + {item.streamingOutput ? ( + + + + ) : ( + + )} ) diff --git a/apps/x/apps/renderer/src/components/terminal-output.tsx b/apps/x/apps/renderer/src/components/terminal-output.tsx new file mode 100644 index 00000000..587616c8 --- /dev/null +++ b/apps/x/apps/renderer/src/components/terminal-output.tsx @@ -0,0 +1,24 @@ +import React, { useMemo } from 'react' +import { processTerminalOutput, spanStyleToCSS } from '../lib/terminal-output' + +export function TerminalOutput({ raw }: { raw: string }) { + const lines = useMemo(() => processTerminalOutput(raw), [raw]) + + return ( + <> + {lines.map((line, lineIdx) => ( + + {lineIdx > 0 && '\n'} + {line.spans.map((span, spanIdx) => { + const css = spanStyleToCSS(span.style) + return css ? ( + {span.text} + ) : ( + {span.text} + ) + })} + + ))} + + ) +} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 150edacb..6ae88d93 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -24,6 +24,7 @@ export interface ToolCall { name: string input: ToolUIPart['input'] result?: ToolUIPart['output'] + streamingOutput?: string status: 'pending' | 'running' | 'completed' | 'error' timestamp: number } diff --git a/apps/x/apps/renderer/src/lib/terminal-output.ts b/apps/x/apps/renderer/src/lib/terminal-output.ts new file mode 100644 index 00000000..fadb0eb7 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/terminal-output.ts @@ -0,0 +1,319 @@ +/** + * Terminal output processor that handles ANSI escape sequences, carriage returns, + * and other terminal control characters to produce styled, terminal-like output. + */ + +export interface StyledSpan { + text: string + style: SpanStyle +} + +export interface SpanStyle { + bold?: boolean + dim?: boolean + italic?: boolean + underline?: boolean + strikethrough?: boolean + fg?: string + bg?: string +} + +export interface TerminalLine { + spans: StyledSpan[] +} + +const ANSI_COLORS_16: Record = { + 30: '#4e4e4e', 31: '#e06c75', 32: '#98c379', 33: '#e5c07b', + 34: '#61afef', 35: '#c678dd', 36: '#56b6c2', 37: '#dcdfe4', + 90: '#5c6370', 91: '#e06c75', 92: '#98c379', 93: '#e5c07b', + 94: '#61afef', 95: '#c678dd', 96: '#56b6c2', 97: '#ffffff', +} + +const ANSI_BG_COLORS_16: Record = { + 40: '#4e4e4e', 41: '#e06c75', 42: '#98c379', 43: '#e5c07b', + 44: '#61afef', 45: '#c678dd', 46: '#56b6c2', 47: '#dcdfe4', + 100: '#5c6370', 101: '#e06c75', 102: '#98c379', 103: '#e5c07b', + 104: '#61afef', 105: '#c678dd', 106: '#56b6c2', 107: '#ffffff', +} + +function color256(n: number): string { + if (n < 8) return ANSI_COLORS_16[30 + n] ?? '#dcdfe4' + if (n < 16) return ANSI_COLORS_16[90 + (n - 8)] ?? '#dcdfe4' + if (n < 232) { + const idx = n - 16 + const r = Math.floor(idx / 36) + const g = Math.floor((idx % 36) / 6) + const b = idx % 6 + const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` + } + const level = 8 + (n - 232) * 10 + const hex = level.toString(16).padStart(2, '0') + return `#${hex}${hex}${hex}` +} + +function parseSGR(params: number[], style: SpanStyle): SpanStyle { + const s = { ...style } + let i = 0 + while (i < params.length) { + const p = params[i] + if (p === 0) { + delete s.bold + delete s.dim + delete s.italic + delete s.underline + delete s.strikethrough + delete s.fg + delete s.bg + } else if (p === 1) s.bold = true + else if (p === 2) s.dim = true + else if (p === 3) s.italic = true + else if (p === 4) s.underline = true + else if (p === 9) s.strikethrough = true + else if (p === 22) { + delete s.bold + delete s.dim + } else if (p === 23) delete s.italic + else if (p === 24) delete s.underline + else if (p === 29) delete s.strikethrough + else if (p >= 30 && p <= 37) s.fg = ANSI_COLORS_16[p] + else if (p === 38) { + if (params[i + 1] === 5 && params[i + 2] !== undefined) { + s.fg = color256(params[i + 2]) + i += 2 + } else if (params[i + 1] === 2 && params[i + 4] !== undefined) { + const r = params[i + 2] + const g = params[i + 3] + const b = params[i + 4] + s.fg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` + i += 4 + } + } else if (p === 39) delete s.fg + else if (p >= 40 && p <= 47) s.bg = ANSI_BG_COLORS_16[p] + else if (p === 48) { + if (params[i + 1] === 5 && params[i + 2] !== undefined) { + s.bg = color256(params[i + 2]) + i += 2 + } else if (params[i + 1] === 2 && params[i + 4] !== undefined) { + const r = params[i + 2] + const g = params[i + 3] + const b = params[i + 4] + s.bg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` + i += 4 + } + } else if (p === 49) delete s.bg + else if (p >= 90 && p <= 97) s.fg = ANSI_COLORS_16[p] + else if (p >= 100 && p <= 107) s.bg = ANSI_BG_COLORS_16[p] + i++ + } + return s +} + +export function processTerminalOutput(raw: string): TerminalLine[] { + type Cell = { char: string; style: SpanStyle } + const lines: Cell[][] = [[]] + let cursorRow = 0 + let cursorCol = 0 + let currentStyle: SpanStyle = {} + + function ensureRow(row: number) { + while (lines.length <= row) lines.push([]) + } + + function ensureCol(row: number, col: number) { + ensureRow(row) + const line = lines[row] + while (line.length <= col) line.push({ char: ' ', style: {} }) + } + + let i = 0 + while (i < raw.length) { + const ch = raw[i] + + if (ch === '\x1b' && i + 1 < raw.length) { + const next = raw[i + 1] + + if (next === '[') { + i += 2 + let paramStr = '' + while (i < raw.length && raw[i] >= '\x20' && raw[i] <= '\x3f') { + paramStr += raw[i] + i++ + } + const finalByte = i < raw.length ? raw[i] : '' + i++ + + const params = paramStr.length > 0 + ? paramStr.split(';').map(s => parseInt(s, 10) || 0) + : [0] + + switch (finalByte) { + case 'm': + currentStyle = parseSGR(params, currentStyle) + break + case 'A': + cursorRow = Math.max(0, cursorRow - (params[0] || 1)) + break + case 'B': + cursorRow += (params[0] || 1) + ensureRow(cursorRow) + break + case 'C': + cursorCol += (params[0] || 1) + break + case 'D': + cursorCol = Math.max(0, cursorCol - (params[0] || 1)) + break + case 'G': + cursorCol = Math.max(0, (params[0] || 1) - 1) + break + case 'H': + case 'f': + cursorRow = Math.max(0, (params[0] || 1) - 1) + cursorCol = Math.max(0, (params[1] || 1) - 1) + ensureRow(cursorRow) + break + case 'J': { + const mode = params[0] || 0 + if (mode === 2 || mode === 3) { + lines.length = 0 + lines.push([]) + cursorRow = 0 + cursorCol = 0 + } else if (mode === 0) { + ensureRow(cursorRow) + lines[cursorRow].length = cursorCol + for (let r = cursorRow + 1; r < lines.length; r++) lines[r] = [] + } else if (mode === 1) { + for (let r = 0; r < cursorRow; r++) lines[r] = [] + ensureCol(cursorRow, cursorCol) + for (let c = 0; c <= cursorCol; c++) lines[cursorRow][c] = { char: ' ', style: {} } + } + break + } + case 'K': { + const mode = params[0] || 0 + ensureRow(cursorRow) + const line = lines[cursorRow] + if (mode === 0) { + line.length = cursorCol + } else if (mode === 1) { + ensureCol(cursorRow, cursorCol) + for (let c = 0; c <= cursorCol; c++) line[c] = { char: ' ', style: {} } + } else if (mode === 2) { + lines[cursorRow] = [] + } + break + } + default: + break + } + continue + } + + if (next === ']') { + i += 2 + while (i < raw.length && raw[i] !== '\x07' && !(raw[i] === '\x1b' && raw[i + 1] === '\\')) { + i++ + } + if (i < raw.length && raw[i] === '\x07') i++ + else if (i < raw.length) i += 2 + continue + } + + i += 2 + continue + } + + if (ch === '\r') { + cursorCol = 0 + i++ + continue + } + + if (ch === '\n') { + cursorRow++ + cursorCol = 0 + ensureRow(cursorRow) + i++ + continue + } + + if (ch === '\b') { + cursorCol = Math.max(0, cursorCol - 1) + i++ + continue + } + + if (ch === '\t') { + const nextTabStop = (Math.floor(cursorCol / 8) + 1) * 8 + while (cursorCol < nextTabStop) { + ensureCol(cursorRow, cursorCol) + lines[cursorRow][cursorCol] = { char: ' ', style: { ...currentStyle } } + cursorCol++ + } + i++ + continue + } + + if (ch.charCodeAt(0) < 32) { + i++ + continue + } + + ensureCol(cursorRow, cursorCol) + lines[cursorRow][cursorCol] = { char: ch, style: { ...currentStyle } } + cursorCol++ + i++ + } + + return lines.map(cells => { + const spans: StyledSpan[] = [] + if (cells.length === 0) return { spans: [{ text: '', style: {} }] } + + let end = cells.length + while (end > 0 && cells[end - 1].char === ' ' && Object.keys(cells[end - 1].style).length === 0) { + end-- + } + + let currentSpan: StyledSpan | null = null + for (let c = 0; c < end; c++) { + const cell = cells[c] + const sameStyle = currentSpan && styleEquals(currentSpan.style, cell.style) + if (sameStyle && currentSpan) { + currentSpan.text += cell.char + } else { + if (currentSpan) spans.push(currentSpan) + currentSpan = { text: cell.char, style: { ...cell.style } } + } + } + if (currentSpan) spans.push(currentSpan) + if (spans.length === 0) spans.push({ text: '', style: {} }) + return { spans } + }) +} + +function styleEquals(a: SpanStyle, b: SpanStyle): boolean { + return a.bold === b.bold + && a.dim === b.dim + && a.italic === b.italic + && a.underline === b.underline + && a.strikethrough === b.strikethrough + && a.fg === b.fg + && a.bg === b.bg +} + +export function spanStyleToCSS(style: SpanStyle): React.CSSProperties | undefined { + if (Object.keys(style).length === 0) return undefined + const css: React.CSSProperties = {} + if (style.fg) css.color = style.fg + if (style.bg) css.backgroundColor = style.bg + if (style.bold) css.fontWeight = 'bold' + if (style.dim) css.opacity = 0.6 + if (style.italic) css.fontStyle = 'italic' + if (style.underline) css.textDecoration = 'underline' + if (style.strikethrough) { + css.textDecoration = css.textDecoration ? `${css.textDecoration} line-through` : 'line-through' + } + return Object.keys(css).length > 0 ? css : undefined +} diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 888474c1..95740d0f 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -176,6 +176,7 @@ export class AgentRuntime implements IAgentRuntime { modelConfigRepo: this.modelConfigRepo, signal, abortRegistry: this.abortRegistry, + bus: this.bus, })) { eventCount++; if (event.type !== "llm-stream-event") { @@ -868,6 +869,7 @@ export async function* streamAgent({ modelConfigRepo, signal, abortRegistry, + bus, }: { state: AgentState, idGenerator: IMonotonicallyIncreasingIdGenerator; @@ -876,6 +878,7 @@ export async function* streamAgent({ modelConfigRepo: IModelConfigRepo; signal: AbortSignal; abortRegistry: IAbortRegistry; + bus: IBus; }): AsyncGenerator, void, unknown> { const logger = new PrefixLogger(`run-${runId}-${state.agentName}`); @@ -985,11 +988,12 @@ export async function* streamAgent({ state: subflowState, idGenerator, runId, - messageQueue, - modelConfigRepo, - signal, - abortRegistry, - })) { + messageQueue, + modelConfigRepo, + signal, + abortRegistry, + bus, + })) { yield* processEvent({ ...event, subflow: [toolCallId, ...event.subflow], @@ -998,9 +1002,15 @@ export async function* streamAgent({ if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) { result = subflowState.finalResponse(); } - } else { - result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry }); - } + } else { + result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { + runId, + toolCallId, + signal, + abortRegistry, + publish: (event) => bus.publish(event), + }); + } } catch (error) { if ((error instanceof Error && error.name === "AbortError") || signal.aborted) { throw error; diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index a455d845..86fe3f9e 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -80,6 +80,8 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, **Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base. +**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx. + **App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view. **Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards. diff --git a/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts new file mode 100644 index 00000000..c2879228 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/code-with-agents/skill.ts @@ -0,0 +1,90 @@ +export const skill = String.raw` +# Code with Agents Skill + +Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex). + +## Important: delegate ALL coding work + +Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes: +- Writing, editing, or refactoring code +- Reading, summarizing, or explaining code +- Debugging and fixing bugs +- Running tests or build commands +- Exploring project structure +- Any other task that involves interacting with a codebase + +Do NOT attempt to do any of these yourself — no reading files, no running commands, no writing code. You are the coordinator; the coding agent does the work. Your job is to translate the user's request into a clear prompt and pass it to the agent. + +## Prerequisites + +The user must have one of the following installed on their machine: +- **Claude Code** — https://claude.ai/code +- **Codex** — https://codex.openai.com + +These are external tools that you cannot install for the user. + +## Workflow + +### Step 1: Gather requirements + +Before running anything, confirm the following with the user: + +1. **Working directory** — Ask which folder the code should be written in, unless the user has already specified it. Example: "Which folder should I work in?" +2. **Agent choice** — Ask whether to use **Claude Code** or **Codex**. Mention that the chosen agent must already be installed on their machine. + +### Step 2: Confirm execution plan + +Once you know the folder and agent, tell the user: + +> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything. + +### Step 3: Execute with acpx + +Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is: + +**For Claude Code:** +` + "`" + ` +npx acpx@latest --approve-all --cwd claude exec "" +` + "`" + ` + +**For Codex:** +` + "`" + ` +npx acpx@latest --approve-all --cwd codex exec "" +` + "`" + ` + +### Critical: flag order + +The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order: + +` + "`" + ` +npx acpx@latest [global flags] exec "" +` + "`" + ` + +**Correct:** +` + "`" + ` +npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug" +` + "`" + ` + +**Wrong (will fail):** +` + "`" + ` +npx acpx@latest claude --approve-all exec "fix the bug" +` + "`" + ` + +### Writing good prompts + +When constructing the prompt for the coding agent: +- Be specific and detailed about what to build or fix +- Include file names, function signatures, and expected behavior +- Mention any constraints (language, framework, style) +- If the user gave you a short request, expand it into a clear, actionable prompt for the agent + +### Step 4: Report results + +After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero. + +Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text. + +- If the exit code is 5, it means permissions were denied — this should not happen with \`--approve-all\`, but if it does, let the user know +`; + +export default skill; 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 f4ba9b1d..fb7ec4e9 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -11,6 +11,7 @@ import createPresentationsSkill from "./create-presentations/skill.js"; import appNavigationSkill from "./app-navigation/skill.js"; import browserControlSkill from "./browser-control/skill.js"; +import codeWithAgentsSkill from "./code-with-agents/skill.js"; import composioIntegrationSkill from "./composio-integration/skill.js"; import tracksSkill from "./tracks/skill.js"; import notifyUserSkill from "./notify-user/skill.js"; @@ -94,6 +95,12 @@ const definitions: SkillDefinition[] = [ summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.", content: appNavigationSkill, }, + { + id: "code-with-agents", + title: "Code with Agents", + summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.", + content: codeWithAgentsSkill, + }, { id: "tracks", title: "Tracks", diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 7dd06dd2..c57d4dfc 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -969,6 +969,16 @@ export const BuiltinTools: z.infer = { const { promise, process: proc } = executeCommandAbortable(command, { cwd: workingDir, signal: ctx.signal, + onData: (chunk: string) => { + ctx.publish({ + runId: ctx.runId, + type: "tool-output-stream", + toolCallId: ctx.toolCallId, + toolName: "executeCommand", + output: chunk, + subflow: [], + }); + }, }); // Register process with abort registry for force-kill diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 11b15d90..005bb7e8 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -143,6 +143,7 @@ export function executeCommandAbortable( timeout?: number; maxBuffer?: number; signal?: AbortSignal; + onData?: (chunk: string) => void; } ): { promise: Promise; process: ChildProcess } { // Check if already aborted before spawning @@ -177,16 +178,20 @@ export function executeCommandAbortable( // Collect output proc.stdout?.on('data', (chunk: Buffer) => { + const text = chunk.toString(); const maxBuffer = options?.maxBuffer || 1024 * 1024; if (stdout.length < maxBuffer) { - stdout += chunk.toString(); + stdout += text; } + options?.onData?.(text); }); proc.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString(); const maxBuffer = options?.maxBuffer || 1024 * 1024; if (stderr.length < maxBuffer) { - stderr += chunk.toString(); + stderr += text; } + options?.onData?.(text); }); // Abort handler 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 09983402..92e87fa6 100644 --- a/apps/x/packages/core/src/application/lib/exec-tool.ts +++ b/apps/x/packages/core/src/application/lib/exec-tool.ts @@ -1,4 +1,5 @@ import { ToolAttachment } from "@x/shared/dist/agent.js"; +import { RunEvent } from "@x/shared/dist/runs.js"; import { z } from "zod"; import { BuiltinTools } from "./builtin-tools.js"; import { executeTool } from "../../mcp/mcp.js"; @@ -9,8 +10,10 @@ import { IAbortRegistry } from "../../runs/abort-registry.js"; */ export interface ToolContext { runId: string; + toolCallId: string; signal: AbortSignal; abortRegistry: IAbortRegistry; + publish: (event: z.infer) => Promise; } async function execMcpTool(agentTool: z.infer & { type: "mcp" }, input: Record): Promise { @@ -34,4 +37,4 @@ export async function execTool(agentTool: z.infer, input: return builtinTool.execute(input, ctx); } } -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index ea93c8a3..7bf7a13d 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -63,6 +63,13 @@ export const ToolResultEvent = BaseRunEvent.extend({ result: z.any(), }); +export const ToolOutputStreamEvent = BaseRunEvent.extend({ + type: z.literal("tool-output-stream"), + toolCallId: z.string(), + toolName: z.string(), + output: z.string(), +}); + export const AskHumanRequestEvent = BaseRunEvent.extend({ type: z.literal("ask-human-request"), toolCallId: z.string(), @@ -106,6 +113,7 @@ export const RunEvent = z.union([ MessageEvent, ToolInvocationEvent, ToolResultEvent, + ToolOutputStreamEvent, AskHumanRequestEvent, AskHumanResponseEvent, ToolPermissionRequestEvent,