feat: 远程群控 (#243)

* feat: restore pipe IPC, LAN pipes, monitor tool, and PR-package features

Core IPC system (UDS_INBOX):
- PipeServer/PipeClient with UDS + TCP dual transport, NDJSON protocol
- PipeRegistry: machineId-based role assignment, file locking
- Master/slave attach, prompt relay, permission forwarding
- Heartbeat lifecycle with parallel isPipeAlive probes
- Commands: /pipes, /attach, /detach, /send, /claim-main, /pipe-status

LAN Pipes (LAN_PIPES):
- UDP multicast beacon (224.0.71.67:7101) for zero-config LAN discovery
- PipeServer TCP listener, PipeClient TCP connect mode
- Heartbeat auto-attaches LAN peers via TCP
- Cross-machine attach allowed regardless of role
- /pipes shows [LAN] peers with role + hostname/IP
- SendMessageTool supports tcp: scheme with user consent

Architecture — extracted hooks from REPL.tsx (~830 lines → ~20 lines):
- usePipeIpc: lifecycle (bootstrap, handlers, heartbeat, cleanup)
- usePipeRelay: slave→master message relay via module singleton
- usePipePermissionForward: permission request/cancel forwarding
- usePipeRouter: selected pipe input routing with role+IP labels
- Shared ndjsonFramer.ts replaces 3 duplicate NDJSON parsers

Key fixes applied during development:
- Multicast binds to correct LAN interface (not WSL/Docker)
- Beacon ref stored as module singleton (not Zustand state mutation)
- Heartbeat preserves LAN peers in discoveredPipes and selectedPipes
- Disconnect handler calls removeSlaveClient (fixes listener leak)
- cleanupStaleEntries probes without lock, writes briefly under lock
- getMachineId uses async execFile (not blocking execSync)
- globalThis.__pipeSendToMaster replaced with setPipeRelay singleton
- M key only toggles route mode when selector panel is expanded
- User prompt displayed in message list on pipe broadcast
- Broadcast notifications show [role] + hostname/IP for LAN peers

Other restored features:
- Monitor tool: /monitor command, MonitorTool, MonitorMcpTask lifecycle
- Daemon supervisor and remoteControlServer command
- Tools: SnipTool, SleepTool, ListPeersTool, SendUserFileTool,
  WebBrowserTool, WorkflowTool, and 10+ stub→implementation rewrites
- Feature flags: UDS_INBOX, LAN_PIPES, MONITOR_TOOL, FORK_SUBAGENT,
  KAIROS, COORDINATOR_MODE, WORKFLOW_SCRIPTS, HISTORY_SNIP

Tests: 2190 pass / 0 fail (15 new: lanBeacon 7, peerAddress 8)

* fix: resolve merge conflicts and fix all tsc/test errors after main merge

- Export ToolResultBlockParam from Tool.ts (14 tool files fixed)
- Migrate ink imports from ../../ink.js to @anthropic/ink (7 files)
- Fix toolUseID → toolUseId typo in monitor.ts and MonitorTool.tsx
- Add fallback values for string|undefined type errors (8 locations)
- Fix AppState type in assistant.ts, add NewInstallWizard stubs
- Fix ParsedRepository.repo → .name in subscribe-pr.ts
- Fix AgentId/string type mismatch in BackgroundTasksDialog.tsx
- Fix PipeRelayFn return type in pipePermissionRelay.ts
- Use PipeMessage type in usePipeRelay.ts
- Fix lanBeacon.test.ts mock type assertions
- Create missing MouseActionEvent class for ink package
- Use ansi: color format instead of bare "green"/"red"
- Resolve theme.permission access via getTheme()

Result: 0 tsc errors, 2496 tests pass, 0 fail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 恢复 /poor 的说明

---------

Co-authored-by: unraid <local@unraid.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-11 23:22:55 +08:00
committed by GitHub
parent 2fea429dc6
commit 09fc515edb
124 changed files with 10958 additions and 577 deletions

View File

@@ -0,0 +1,80 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const CTX_INSPECT_TOOL_NAME = 'CtxInspect'
const inputSchema = lazySchema(() =>
z.strictObject({
query: z
.string()
.optional()
.describe('Optional query to filter context entries. If omitted, returns a summary of all context.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type CtxInput = z.infer<InputSchema>
type CtxOutput = {
total_tokens: number
message_count: number
summary: string
}
export const CtxInspectTool = buildTool({
name: CTX_INSPECT_TOOL_NAME,
searchHint: 'context inspect tokens usage messages window collapse',
maxResultSizeChars: 50_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Inspect the current context window contents and token usage'
},
async prompt() {
return `Inspect the current conversation context. Shows token usage, message count, and a breakdown of what's consuming context space.
Use this to understand your context budget before deciding whether to snip old messages or adjust your approach.`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'CtxInspect'
},
renderToolUseMessage() {
return 'Context Inspect'
},
mapToolResultToToolResultBlockParam(
content: CtxOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Context: ${content.total_tokens} tokens, ${content.message_count} messages\n${content.summary}`,
}
},
async call() {
// Context inspection is wired into the context collapse system.
return {
data: {
total_tokens: 0,
message_count: 0,
summary: 'Context inspection requires the CONTEXT_COLLAPSE runtime.',
},
}
},
})

View File

@@ -0,0 +1,107 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const LIST_PEERS_TOOL_NAME = 'ListPeers'
const inputSchema = lazySchema(() =>
z.strictObject({
include_self: z
.boolean()
.optional()
.describe('Whether to include the current session in the list. Defaults to false.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type ListPeersInput = z.infer<InputSchema>
type PeerInfo = {
address: string
name?: string
cwd?: string
pid?: number
}
type ListPeersOutput = { peers: PeerInfo[] }
export const ListPeersTool = buildTool({
name: LIST_PEERS_TOOL_NAME,
searchHint: 'list peers sessions discover uds socket messaging',
maxResultSizeChars: 50_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Discover other Claude Code sessions for cross-session messaging'
},
async prompt() {
return `List active Claude Code sessions that can receive messages via SendMessage.
Returns an array of peers with their addresses. Use these addresses as the \`to\` field in SendMessage:
- \`"uds:/path/to.sock"\` — local sessions on the same machine (Unix Domain Socket)
- \`"bridge:session_..."\` — remote sessions via Remote Control
Use this tool to discover messaging targets before sending cross-session messages. Only running sessions with active messaging sockets are returned.`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return LIST_PEERS_TOOL_NAME
},
renderToolUseMessage() {
return 'ListPeers'
},
mapToolResultToToolResultBlockParam(
content: ListPeersOutput,
toolUseID: string,
): ToolResultBlockParam {
const lines = content.peers.map(
p => `${p.address}${p.name ? ` (${p.name})` : ''}${p.cwd ? ` @ ${p.cwd}` : ''}`,
)
return {
tool_use_id: toolUseID,
type: 'tool_result',
content:
lines.length > 0
? `Found ${lines.length} peer(s):\n${lines.join('\n')}`
: 'No peers found.',
}
},
async call(_input: ListPeersInput, context) {
// Peer discovery uses the concurrent sessions PID registry and
// UDS socket directory. The implementation scans for live sockets
// and optionally includes Remote Control bridge peers.
const peers: PeerInfo[] = []
// Discovery is handled by the UDS messaging subsystem initialized in setup.ts.
// Return discovered peers from the app state.
const appState = context.getAppState()
const messagingSocketPath = (appState as Record<string, unknown>).messagingSocketPath as string | undefined
if (messagingSocketPath) {
// Self entry for reference
if (_input.include_self) {
peers.push({
address: `uds:${messagingSocketPath}`,
name: 'self',
pid: process.pid,
})
}
}
return {
data: { peers },
}
},
})

View File

@@ -1,3 +0,0 @@
// Auto-generated stub — replace with real implementation
export {};
export const MonitorTool: Record<string, unknown> = {};

View File

@@ -0,0 +1,190 @@
import React from 'react'
import { Text } from '@anthropic/ink'
import { z } from 'zod/v4'
import { TOOL_SUMMARY_MAX_LENGTH } from '../../constants/toolLimits.js'
import type { ToolResultBlockParam, ToolUseContext, ValidationResult } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { spawnShellTask } from '../../tasks/LocalShellTask/LocalShellTask.js'
import { bashToolHasPermission } from '../BashTool/bashPermissions.js'
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { truncate } from '../../utils/format.js'
import { exec } from '../../utils/Shell.js'
import { getTaskOutputPath } from '../../utils/task/diskOutput.js'
import { logEvent } from '../../services/analytics/index.js'
const MONITOR_TOOL_NAME = 'Monitor'
const inputSchema = lazySchema(() =>
z.strictObject({
command: z
.string()
.describe(
'The shell command to run as a long-running monitor. Should produce streaming output (e.g., tail -f, watch, polling loops).',
),
description: z
.string()
.describe(
'Clear, concise description of what this monitor watches. Used as the label in the background tasks UI.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
export type MonitorInput = z.infer<InputSchema>
const outputSchema = lazySchema(() =>
z.object({
taskId: z.string(),
outputFile: z.string(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type MonitorOutput = z.infer<OutputSchema>
export const MonitorTool = buildTool({
name: MONITOR_TOOL_NAME,
searchHint: 'start long-running background monitor for streaming events',
maxResultSizeChars: 10_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
async description() {
return 'Start a long-running background monitor'
},
async prompt() {
return `Use Monitor to start a long-running background process that streams output (watching logs, polling APIs, tailing files, etc.). The command runs in the background and you receive a notification when it exits. Use the Read tool with the output file path to check its output at any time.
Guidelines:
- Use Monitor for commands that produce ongoing streaming output: \`tail -f\`, log watchers, file watchers, API polling loops, \`watch\` commands
- Do NOT use Monitor for one-shot commands that finish quickly — use Bash for those
- Do NOT use Monitor for commands that need interactive input — they will hang
- The description should clearly explain what is being monitored
- You'll get a task notification when the monitor process exits (stream ends, script fails, or killed)
- To check output at any time, use Read on the output file path returned by this tool
Examples:
- Watching a log file: command="tail -f /var/log/app.log", description="Watch app log for errors"
- Polling an API: command="while true; do curl -s http://localhost:3000/health; sleep 5; done", description="Poll health endpoint every 5s"
- Watching for file changes: command="inotifywait -m -r ./src", description="Watch src directory for file changes"`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
// Monitor executes shell commands which may have side effects
return false
},
toAutoClassifierInput(input: MonitorInput) {
return `Monitor: ${input.command}`
},
async checkPermissions(
input: MonitorInput,
context: ToolUseContext,
): Promise<PermissionResult> {
// Reuse bash permission checking for the underlying command
return bashToolHasPermission(
{ command: input.command },
context,
)
},
userFacingName() {
return MONITOR_TOOL_NAME
},
getActivityDescription(input: MonitorInput) {
if (!input?.description) {
return 'Starting monitor'
}
return `Monitoring: ${truncate(input.description, TOOL_SUMMARY_MAX_LENGTH)}`
},
async validateInput(input: MonitorInput): Promise<ValidationResult> {
if (!input.command || input.command.trim() === '') {
return {
result: false,
message: 'Monitor command cannot be empty.',
errorCode: 1,
}
}
if (!input.description || input.description.trim() === '') {
return {
result: false,
message: 'Monitor description cannot be empty.',
errorCode: 2,
}
}
return { result: true }
},
async call(input: MonitorInput, context: ToolUseContext) {
const { command, description } = input
const {
abortController,
setAppState,
toolUseId,
agentId,
} = context
logEvent('tengu_monitor_tool_used', {})
// Create the shell command via exec
const shellCommand = await exec(command, abortController.signal, 'bash')
// Spawn as a background task with kind: 'monitor'
const handle = await spawnShellTask(
{
command,
description,
shellCommand,
toolUseId: toolUseId,
agentId,
kind: 'monitor',
},
{
abortController,
getAppState: context.getAppState,
setAppState,
},
)
const outputFile = getTaskOutputPath(handle.taskId)
return {
data: {
taskId: handle.taskId,
outputFile,
},
}
},
renderToolUseMessage(input: MonitorInput, { verbose }) {
const desc = truncate(input.description || input.command, 80)
return `Monitor: ${desc}`
},
mapToolResultToToolResultBlockParam(
content: MonitorOutput,
toolUseId: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseId,
type: 'tool_result',
content: `Monitor started (task ${content.taskId}). Output file: ${content.outputFile}`,
}
},
renderToolResultMessage(output: MonitorOutput) {
return <Text>Monitor started (task {output.taskId}). Output: {output.outputFile}</Text>
},
})

View File

@@ -7,10 +7,6 @@ mock.module("src/utils/cwd.js", () => ({
getCwd: () => mockCwd,
}));
mock.module("src/utils/powershell/parser.js", () => ({
PS_TOKENIZER_DASH_CHARS: new Set(["-", "\u2013", "\u2014", "\u2015"]),
}));
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
describe("isGitInternalPathPS", () => {

View File

@@ -0,0 +1,87 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification'
const inputSchema = lazySchema(() =>
z.strictObject({
title: z
.string()
.describe('Title of the push notification.'),
body: z
.string()
.describe('Body text of the push notification.'),
priority: z
.enum(['normal', 'high'])
.optional()
.describe('Notification priority. Use "high" for blockers or permission prompts.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type PushInput = z.infer<InputSchema>
type PushOutput = { sent: boolean }
export const PushNotificationTool = buildTool({
name: PUSH_NOTIFICATION_TOOL_NAME,
searchHint: 'push notification mobile alert notify user',
maxResultSizeChars: 1_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Send a push notification to the user\'s mobile device'
},
async prompt() {
return `Send a push notification to the user's mobile device via Remote Control.
Use this when:
- A long-running task completes and the user may not be watching
- A permission prompt is waiting and you need user input
- Something urgent requires the user's attention
Requires Remote Control to be configured. Respects user notification settings (taskCompleteNotifEnabled, inputNeededNotifEnabled, agentPushNotifEnabled).`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'Notify'
},
renderToolUseMessage(input: Partial<PushInput>) {
return `Push: ${input.title ?? '...'}`
},
mapToolResultToToolResultBlockParam(
content: PushOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.sent ? 'Notification sent.' : 'Failed to send notification.',
}
},
async call(_input: PushInput) {
// Push delivery is handled by the Remote Control / KAIROS transport layer.
// Without the KAIROS runtime, this tool is not available.
return {
data: {
sent: false,
error: 'PushNotification requires the KAIROS transport layer.',
},
}
},
})

View File

@@ -1 +0,0 @@
export const REPLTool = { name: 'REPLTool', isEnabled: () => false }

View File

@@ -0,0 +1,89 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { REPL_TOOL_NAME } from './constants.js'
const inputSchema = lazySchema(() =>
z.strictObject({
code: z
.string()
.describe(
'The code to execute in the REPL. Can call any primitive tool (Read, Write, Edit, Glob, Grep, Bash, NotebookEdit, Agent) via their APIs.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type REPLInput = z.infer<InputSchema>
type REPLOutput = { result: string; tool_calls: number }
export const REPLTool = buildTool({
name: REPL_TOOL_NAME,
searchHint: 'repl execute batch code read write edit glob grep bash',
maxResultSizeChars: 100_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Execute code in the REPL environment with access to all primitive tools'
},
async prompt() {
return `Execute code in the REPL — a sandboxed environment with direct access to primitive tools (Read, Write, Edit, Glob, Grep, Bash, NotebookEdit, Agent).
When REPL mode is active, primitive tools are only accessible through this tool. Use REPL for:
- Batch operations across many files
- Complex multi-step file transformations
- Operations that benefit from programmatic control flow
- Combining search results with edits in a single turn
The REPL runs in a VM context with tool APIs available as functions. Results from each tool call are collected and returned together.`
},
isConcurrencySafe() {
return false
},
isReadOnly() {
return false
},
isTransparentWrapper() {
return true
},
userFacingName() {
return REPL_TOOL_NAME
},
renderToolUseMessage(input: Partial<REPLInput>) {
const code = input.code ?? ''
const preview = code.length > 80 ? code.slice(0, 77) + '...' : code
return `REPL: ${preview}`
},
mapToolResultToToolResultBlockParam(
content: REPLOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.result,
}
},
async call(_input: REPLInput) {
// REPL execution engine is provided by the ant-native runtime.
// This stub satisfies the tool interface; the actual VM dispatch
// is wired in the ant build. Without the ant runtime, REPL is
// not available and callers should be informed.
return {
data: {
result: 'Error: REPL tool is not available in this build. The REPL execution engine requires the ant-native runtime.',
tool_calls: 0,
},
}
},
})

View File

@@ -1,3 +1,142 @@
// Auto-generated stub — replace with real implementation
export {};
export const ReviewArtifactTool: Record<string, unknown> = {};
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import React from 'react'
import { Box, Text } from '@anthropic/ink'
const REVIEW_ARTIFACT_TOOL_NAME = 'ReviewArtifact'
const DESCRIPTION =
'Review an artifact (code snippet, document, or other content) with inline annotations and feedback.'
const inputSchema = lazySchema(() =>
z.strictObject({
artifact: z
.string()
.describe(
'The content of the artifact to review (code snippet, document text, etc.).',
),
title: z
.string()
.optional()
.describe(
'Optional title or file path for the artifact being reviewed.',
),
annotations: z
.array(
z.object({
line: z.number().optional().describe('Line number for the annotation (1-based).'),
message: z.string().describe('The annotation or feedback message.'),
severity: z
.enum(['info', 'warning', 'error', 'suggestion'])
.optional()
.describe('Severity level of the annotation.'),
}),
)
.describe('List of annotations/comments on the artifact.'),
summary: z
.string()
.optional()
.describe('An overall summary of the review.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
artifact: z.string().describe('The reviewed artifact content.'),
title: z.string().optional().describe('Title of the reviewed artifact.'),
annotationCount: z
.number()
.describe('Number of annotations applied.'),
summary: z.string().optional().describe('Summary of the review.'),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const ReviewArtifactTool = buildTool({
name: REVIEW_ARTIFACT_TOOL_NAME,
searchHint: 'review code or documents with inline annotations',
maxResultSizeChars: 100_000,
async description(input) {
const { title } = input as { title?: string }
return title
? `Claude wants to review: ${title}`
: 'Claude wants to review an artifact'
},
userFacingName() {
return 'ReviewArtifact'
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
toAutoClassifierInput(input) {
return input.title ?? input.artifact.slice(0, 200)
},
async prompt() {
return `Use this tool to present a review of a code snippet, document, or other artifact with inline annotations and feedback. Each annotation can target a specific line and include a severity level. ${DESCRIPTION}`
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Review delivered with ${output.annotationCount} annotation(s).${output.summary ? ` Summary: ${output.summary}` : ''}`,
}
},
renderToolUseMessage(
input: Partial<z.infer<InputSchema>>,
{ verbose }: { theme?: string; verbose: boolean },
): React.ReactNode {
const title = input.title ?? 'Untitled artifact'
const count = input.annotations?.length ?? 0
if (verbose) {
return `Review: "${title}" (${count} annotation(s))`
}
return title
},
renderToolResultMessage(
output: Output,
_progressMessages: unknown[],
{ verbose }: { verbose: boolean },
): React.ReactNode {
if (verbose) {
return React.createElement(
Box,
{ flexDirection: 'column' },
React.createElement(
Text,
null,
`Reviewed artifact: ${output.title ?? 'Untitled'} (${output.annotationCount} annotations)`,
),
output.summary
? React.createElement(Text, { dimColor: true }, output.summary)
: null,
)
}
return React.createElement(
Text,
null,
`Review complete: ${output.annotationCount} annotation(s)`,
)
},
async call({ artifact, title, annotations, summary }, _context) {
const output: Output = {
artifact,
title,
annotationCount: annotations.length,
summary,
}
return { data: output }
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -70,7 +70,7 @@ const inputSchema = lazySchema(() =>
.string()
.describe(
feature('UDS_INBOX')
? 'Recipient: teammate name, "*" for broadcast, "uds:<socket-path>" for a local peer, or "bridge:<session-id>" for a Remote Control peer (use ListPeers to discover)'
? `Recipient: teammate name, "*" for broadcast, "uds:<socket-path>" for a local peer, "bridge:<session-id>" for a Remote Control peer${feature('LAN_PIPES') ? ', or "tcp:<host>:<port>" for a LAN peer' : ''} (use ListPeers to discover)`
: 'Recipient: teammate name, or "*" for broadcast to all teammates',
),
summary: z
@@ -587,9 +587,6 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
return {
behavior: 'ask' as const,
message: `Send a message to Remote Control session ${input.to}? It arrives as a user prompt on the receiving Claude (possibly another machine) via Anthropic's servers.`,
// safetyCheck (not mode) — permissions.ts guards this before both
// bypassPermissions (step 1g) and auto-mode's allowlist/classifier.
// Cross-machine prompt injection must stay bypass-immune.
decisionReason: {
type: 'safetyCheck',
reason:
@@ -598,6 +595,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
}
}
if (feature('LAN_PIPES') && parseAddress(input.to).scheme === 'tcp') {
return {
behavior: 'ask' as const,
message: `Send a message to LAN peer ${input.to}? This connects directly over TCP to a machine on your local network.`,
decisionReason: {
type: 'safetyCheck',
reason: 'Cross-machine LAN message requires explicit user consent',
classifierApprovable: false,
},
}
}
return { behavior: 'allow' as const, updatedInput: input }
},
@@ -611,7 +619,9 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
}
const addr = parseAddress(input.to)
if (
(addr.scheme === 'bridge' || addr.scheme === 'uds') &&
(addr.scheme === 'bridge' ||
addr.scheme === 'uds' ||
addr.scheme === 'tcp') &&
addr.target.trim().length === 0
) {
return {
@@ -659,9 +669,13 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
parseAddress(input.to).scheme === 'uds' &&
typeof input.message === 'string'
) {
// UDS cross-session send: summary isn't rendered (UI.tsx returns null
// for string messages), so don't require it. Structured messages fall
// through to the rejection below.
return { result: true }
}
if (
feature('LAN_PIPES') &&
parseAddress(input.to).scheme === 'tcp' &&
typeof input.message === 'string'
) {
return { result: true }
}
if (typeof input.message === 'string') {
@@ -783,7 +797,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
return {
data: {
success: true,
message: `${preview}” → ${input.to}`,
message: `${preview}” → ${input.to}`,
},
}
} catch (e) {
@@ -795,6 +809,41 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
}
}
}
if (addr.scheme === 'tcp' && feature('LAN_PIPES')) {
const { parseTcpTarget } =
require('../../utils/peerAddress.js') as typeof import('../../utils/peerAddress.js')
const { PipeClient } =
require('../../utils/pipeTransport.js') as typeof import('../../utils/pipeTransport.js')
const ep = parseTcpTarget(addr.target)
if (!ep) {
return {
data: {
success: false,
message: `Invalid TCP target format: ${addr.target}. Expected host:port`,
},
}
}
try {
const client = new PipeClient(input.to, `send-${process.pid}`, ep)
await client.connect(5000)
client.send({ type: 'chat', data: input.message })
client.disconnect()
const preview = input.summary || truncate(input.message, 50)
return {
data: {
success: true,
message: `${preview}” → ${input.to} (TCP ${ep.host}:${ep.port})`,
},
}
} catch (e) {
return {
data: {
success: false,
message: `Failed to send via TCP to ${input.to}: ${errorMessage(e)}`,
},
}
}
}
}
// Route to in-process subagent by name or raw agentId before falling
@@ -826,7 +875,9 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
prompt: input.message,
toolUseContext: context,
canUseTool,
invokingRequestId: assistantMessage?.requestId as string | undefined,
invokingRequestId: assistantMessage?.requestId as
| string
| undefined,
})
return {
data: {
@@ -853,7 +904,9 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
prompt: input.message,
toolUseContext: context,
canUseTool,
invokingRequestId: assistantMessage?.requestId as string | undefined,
invokingRequestId: assistantMessage?.requestId as
| string
| undefined,
})
return {
data: {

View File

@@ -0,0 +1,84 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { SEND_USER_FILE_TOOL_NAME } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
file_path: z
.string()
.describe('Absolute path to the file to send to the user.'),
description: z
.string()
.optional()
.describe('Optional description of the file being sent.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type SendUserFileInput = z.infer<InputSchema>
type SendUserFileOutput = { sent: boolean; file_path: string }
export const SendUserFileTool = buildTool({
name: SEND_USER_FILE_TOOL_NAME,
searchHint: 'send file to user mobile device upload share',
maxResultSizeChars: 5_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Send a file to the user (KAIROS assistant mode)'
},
async prompt() {
return `Send a file to the user's device. Use this in assistant mode when the user requests a file or when a file is relevant to the conversation.
Guidelines:
- Use absolute paths
- The file must exist and be readable
- Large files may take time to transfer`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'SendFile'
},
renderToolUseMessage(input: Partial<SendUserFileInput>) {
return `Send file: ${input.file_path ?? '...'}`
},
mapToolResultToToolResultBlockParam(
content: SendUserFileOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.sent
? `File sent: ${content.file_path}`
: `Failed to send file: ${content.file_path}`,
}
},
async call(_input: SendUserFileInput) {
// File transfer is handled by the KAIROS assistant transport layer.
// Without the KAIROS runtime, this tool is not available.
return {
data: {
sent: false,
file_path: _input.file_path,
error: 'SendUserFile requires the KAIROS assistant transport layer.',
},
}
},
})

View File

@@ -1,3 +1 @@
// Auto-generated stub — replace with real implementation
export {};
export const SEND_USER_FILE_TOOL_NAME: string = '';
export const SEND_USER_FILE_TOOL_NAME = 'SendUserFile'

View File

@@ -0,0 +1,134 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
duration_seconds: z
.number()
.describe(
'How long to sleep in seconds. Can be interrupted by the user at any time.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type SleepInput = z.infer<InputSchema>
type SleepOutput = { slept_seconds: number; interrupted: boolean }
export const SleepTool = buildTool({
name: SLEEP_TOOL_NAME,
searchHint: 'wait pause sleep rest idle duration timer',
maxResultSizeChars: 1_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return DESCRIPTION
},
async prompt() {
return SLEEP_TOOL_PROMPT
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return SLEEP_TOOL_NAME
},
renderToolUseMessage(input: Partial<SleepInput>) {
const secs = input.duration_seconds ?? '?'
return `Sleep: ${secs}s`
},
mapToolResultToToolResultBlockParam(
content: SleepOutput,
toolUseID: string,
): ToolResultBlockParam {
const msg = content.interrupted
? `Sleep interrupted after ${content.slept_seconds}s`
: `Slept for ${content.slept_seconds}s`
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: msg,
}
},
async call(input: SleepInput, context) {
// Refuse to sleep when proactive mode is off — prevents the model from
// re-issuing Sleep after an interruption caused by /proactive disable.
if (feature('PROACTIVE') || feature('KAIROS')) {
const mod =
require('../../proactive/index.js') as typeof import('../../proactive/index.js')
if (!mod.isProactiveActive()) {
return {
data: {
slept_seconds: 0,
interrupted: true,
},
}
}
}
const { duration_seconds } = input
const startTime = Date.now()
try {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, duration_seconds * 1000)
// Abort via user interrupt
context.abortController.signal.addEventListener(
'abort',
() => {
clearTimeout(timer)
clearInterval(proactiveCheck)
reject(new Error('interrupted'))
},
{ once: true },
)
// Poll proactive state — if deactivated mid-sleep, interrupt early
// so the user doesn't have to wait for the full duration.
const proactiveCheck =
feature('PROACTIVE') || feature('KAIROS')
? setInterval(() => {
const mod =
require('../../proactive/index.js') as typeof import('../../proactive/index.js')
if (!mod.isProactiveActive()) {
clearTimeout(timer)
clearInterval(proactiveCheck)
reject(new Error('interrupted'))
}
}, 500)
: (null as unknown as ReturnType<typeof setInterval>)
})
return {
data: {
slept_seconds: duration_seconds,
interrupted: false,
},
}
} catch {
const elapsed = Math.round((Date.now() - startTime) / 1000)
return {
data: {
slept_seconds: elapsed,
interrupted: true,
},
}
}
},
})

View File

@@ -0,0 +1,92 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { SNIP_TOOL_NAME } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
message_ids: z
.array(z.string())
.describe(
'IDs of the messages to snip from history. Snipped messages are replaced with a short summary.',
),
reason: z
.string()
.optional()
.describe(
'Why these messages are being snipped. Used in the summary replacement.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type SnipInput = z.infer<InputSchema>
type SnipOutput = { snipped_count: number; summary: string }
export const SnipTool = buildTool({
name: SNIP_TOOL_NAME,
searchHint: 'snip trim history remove old messages compact context',
maxResultSizeChars: 5_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Snip messages from conversation history to free up context'
},
async prompt() {
return `Snip messages from your conversation history to free up context window space. Snipped messages are replaced with a compact summary so you retain awareness of what happened without the full content.
Use this when:
- Your context is getting full and you need to make room
- Earlier messages contain large tool outputs you no longer need in full
- You want to compact a long exploration sequence into a summary
Guidelines:
- Only snip messages you're confident you won't need verbatim again
- The summary replacement preserves key facts (file paths, decisions, errors found)
- You cannot un-snip — the original content is gone from context`
},
isConcurrencySafe() {
return false
},
isReadOnly() {
return false
},
userFacingName() {
return 'Snip'
},
renderToolUseMessage(input: Partial<SnipInput>) {
const count = input.message_ids?.length ?? 0
return `Snip: ${count} message${count !== 1 ? 's' : ''}`
},
mapToolResultToToolResultBlockParam(
content: SnipOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Snipped ${content.snipped_count} messages. Summary: ${content.summary}`,
}
},
async call(input: SnipInput) {
// Snip implementation is handled by the query engine's projection system.
// The tool call itself records the intent; the query engine intercepts
// snip tool results and adjusts its message projection accordingly.
return {
data: {
snipped_count: input.message_ids.length,
summary: input.reason ?? `Snipped ${input.message_ids.length} messages`,
},
}
},
})

View File

@@ -1,3 +1 @@
// Auto-generated stub — replace with real implementation
export {};
export const SNIP_TOOL_NAME: string = '';
export const SNIP_TOOL_NAME = 'Snip'

View File

@@ -0,0 +1,88 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const SUBSCRIBE_PR_TOOL_NAME = 'SubscribePR'
const inputSchema = lazySchema(() =>
z.strictObject({
repo: z
.string()
.describe('Repository in owner/repo format.'),
pr_number: z
.number()
.describe('Pull request number to subscribe to.'),
events: z
.array(z.enum(['comment', 'review', 'ci', 'merge', 'close']))
.optional()
.describe('Event types to subscribe to. Defaults to all events.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type SubscribeInput = z.infer<InputSchema>
type SubscribeOutput = { subscribed: boolean; subscription_id: string }
export const SubscribePRTool = buildTool({
name: SUBSCRIBE_PR_TOOL_NAME,
searchHint: 'subscribe pull request github webhook events watch',
maxResultSizeChars: 5_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Subscribe to pull request events via GitHub webhooks'
},
async prompt() {
return `Subscribe to events on a GitHub pull request. You'll receive notifications when selected events occur (comments, reviews, CI status changes, merge, close).
Use this to monitor PRs you've created or are reviewing. Events are delivered as messages you can act on.`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'SubscribePR'
},
renderToolUseMessage(input: Partial<SubscribeInput>) {
const pr = input.repo && input.pr_number
? `${input.repo}#${input.pr_number}`
: '...'
return `Subscribe PR: ${pr}`
},
mapToolResultToToolResultBlockParam(
content: SubscribeOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.subscribed
? `Subscribed to PR events (id: ${content.subscription_id})`
: 'Failed to subscribe to PR events.',
}
},
async call(_input: SubscribeInput) {
// Webhook subscription is managed by the KAIROS GitHub webhook subsystem.
// Without the KAIROS runtime, this tool is not available.
return {
data: {
subscribed: false,
subscription_id: '',
error: 'SubscribePR requires the KAIROS GitHub webhook subsystem.',
},
}
},
})

View File

@@ -1 +0,0 @@
export const SuggestBackgroundPRTool = { name: 'SuggestBackgroundPRTool', isEnabled: () => false }

View File

@@ -0,0 +1,84 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const SUGGEST_BACKGROUND_PR_TOOL_NAME = 'SuggestBackgroundPR'
const inputSchema = lazySchema(() =>
z.strictObject({
title: z
.string()
.describe('Suggested title for the background PR.'),
description: z
.string()
.describe('Description of the changes to make in the background PR.'),
branch: z
.string()
.optional()
.describe('Branch name for the PR. Auto-generated if omitted.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type SuggestInput = z.infer<InputSchema>
type SuggestOutput = { suggested: boolean; suggestion_id: string }
export const SuggestBackgroundPRTool = buildTool({
name: SUGGEST_BACKGROUND_PR_TOOL_NAME,
searchHint: 'suggest background pr pull request create',
maxResultSizeChars: 5_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Suggest creating a background PR for follow-up changes'
},
async prompt() {
return `Suggest creating a pull request in the background for follow-up work. Use this when you identify improvements or cleanup that should be done but aren't part of the current task.
The suggestion is presented to the user who can approve or dismiss it. If approved, a background agent creates the PR.`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'SuggestPR'
},
renderToolUseMessage(input: Partial<SuggestInput>) {
return `Suggest PR: ${input.title ?? '...'}`
},
mapToolResultToToolResultBlockParam(
content: SuggestOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.suggested
? `PR suggestion recorded (id: ${content.suggestion_id})`
: 'Failed to record PR suggestion.',
}
},
async call(_input: SuggestInput) {
// Background PR suggestion requires the KAIROS runtime.
return {
data: {
suggested: false,
suggestion_id: '',
error: 'SuggestBackgroundPR requires the KAIROS runtime.',
},
}
},
})

View File

@@ -0,0 +1,82 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { TERMINAL_CAPTURE_TOOL_NAME } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
lines: z
.number()
.optional()
.describe('Number of lines to capture from the terminal. Defaults to 50.'),
panel_id: z
.string()
.optional()
.describe('ID of the terminal panel to capture from. Defaults to the active panel.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type CaptureInput = z.infer<InputSchema>
type CaptureOutput = { content: string; line_count: number }
export const TerminalCaptureTool = buildTool({
name: TERMINAL_CAPTURE_TOOL_NAME,
searchHint: 'terminal capture screen output panel read',
maxResultSizeChars: 100_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Capture output from a terminal panel'
},
async prompt() {
return `Capture the current content of a terminal panel. Use this to read output from terminal sessions running in the terminal panel UI.
Guidelines:
- Specify the number of lines to capture (default 50)
- Optionally target a specific panel by ID
- Content is returned as plain text`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'TerminalCapture'
},
renderToolUseMessage(input: Partial<CaptureInput>) {
const lines = input.lines ?? 50
return `Terminal Capture: ${lines} lines`
},
mapToolResultToToolResultBlockParam(
content: CaptureOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.content || '(empty terminal)',
}
},
async call(input: CaptureInput) {
// Terminal panel capture is provided by the TERMINAL_PANEL runtime.
return {
data: {
content: '',
line_count: 0,
},
}
},
})

View File

@@ -1,3 +1 @@
// Auto-generated stub — replace with real implementation
export {};
export const TERMINAL_CAPTURE_TOOL_NAME: string = '';
export const TERMINAL_CAPTURE_TOOL_NAME = 'TerminalCapture'

View File

@@ -1 +0,0 @@
export const VerifyPlanExecutionTool = { name: 'VerifyPlanExecutionTool', isEnabled: () => false }

View File

@@ -0,0 +1,93 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { VERIFY_PLAN_EXECUTION_TOOL_NAME } from './constants.js'
const inputSchema = lazySchema(() =>
z.strictObject({
plan_summary: z
.string()
.describe('A summary of the plan that was executed.'),
verification_notes: z
.string()
.optional()
.describe(
'Notes on what was verified and any issues found during verification.',
),
all_steps_completed: z
.boolean()
.describe('Whether all planned steps were completed successfully.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type VerifyInput = z.infer<InputSchema>
type VerifyOutput = { verified: boolean; summary: string }
export const VerifyPlanExecutionTool = buildTool({
name: VERIFY_PLAN_EXECUTION_TOOL_NAME,
searchHint: 'verify plan execution check completion',
maxResultSizeChars: 10_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Verify that a plan was executed correctly before exiting plan mode'
},
async prompt() {
return `Verify that a plan has been executed correctly. Call this tool before exiting plan mode to confirm all steps were completed.
Guidelines:
- Summarize the plan that was executed
- Note whether all steps completed successfully
- Include any verification notes (tests passed, files created, etc.)
- If steps were skipped or failed, explain why in verification_notes`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'VerifyPlan'
},
renderToolUseMessage(input: Partial<VerifyInput>) {
if (input.all_steps_completed === true) {
return 'Verify Plan: all steps completed'
}
if (input.all_steps_completed === false) {
return 'Verify Plan: incomplete'
}
return 'Verify Plan'
},
mapToolResultToToolResultBlockParam(
content: VerifyOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.verified
? `Plan verified: ${content.summary}`
: `Plan verification failed: ${content.summary}`,
}
},
async call(input: VerifyInput) {
return {
data: {
verified: input.all_steps_completed,
summary: input.plan_summary,
},
}
},
})

View File

@@ -1,3 +1 @@
// Auto-generated stub — replace with real implementation
export {};
export const VERIFY_PLAN_EXECUTION_TOOL_NAME: string = '';
export const VERIFY_PLAN_EXECUTION_TOOL_NAME = 'VerifyPlanExecution'

View File

@@ -0,0 +1,97 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const WEB_BROWSER_TOOL_NAME = 'WebBrowser'
const inputSchema = lazySchema(() =>
z.strictObject({
url: z
.string()
.describe('URL to navigate to in the browser.'),
action: z
.enum(['navigate', 'screenshot', 'click', 'type', 'scroll'])
.optional()
.describe('Browser action to perform. Defaults to "navigate".'),
selector: z
.string()
.optional()
.describe('CSS selector for click/type actions.'),
text: z
.string()
.optional()
.describe('Text to type when action is "type".'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type BrowserInput = z.infer<InputSchema>
type BrowserOutput = {
title: string
url: string
content?: string
screenshot?: string
}
export const WebBrowserTool = buildTool({
name: WEB_BROWSER_TOOL_NAME,
searchHint: 'web browser navigate url page screenshot click',
maxResultSizeChars: 100_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Browse the web using an embedded browser'
},
async prompt() {
return `Open and interact with web pages in an embedded browser. Supports navigation, screenshots, clicking, typing, and scrolling.
Use this for:
- Viewing web pages and their content
- Taking screenshots of UI
- Interacting with web applications
- Testing web endpoints with full browser rendering`
},
isConcurrencySafe() {
return false
},
isReadOnly() {
return true
},
userFacingName() {
return 'Browser'
},
renderToolUseMessage(input: Partial<BrowserInput>) {
const action = input.action ?? 'navigate'
return `Browser ${action}: ${input.url ?? '...'}`
},
mapToolResultToToolResultBlockParam(
content: BrowserOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `${content.title} (${content.url})\n${content.content ?? ''}`,
}
},
async call(input: BrowserInput) {
// Browser integration requires the WEB_BROWSER_TOOL runtime (Bun WebView).
return {
data: {
title: '',
url: input.url,
content: 'Web browser requires the WEB_BROWSER_TOOL runtime.',
},
}
},
})

View File

@@ -1,3 +0,0 @@
// Auto-generated stub — replace with real implementation
export {};
export const WorkflowPermissionRequest: (props: Record<string, unknown>) => null = () => null;

View File

@@ -0,0 +1,166 @@
import React, { useCallback, useMemo } from 'react'
import { Box, Text, useTheme } from '@anthropic/ink'
import { getTheme } from '../../utils/theme.js'
import { env } from '../../utils/env.js'
import { shouldShowAlwaysAllowOptions } from '../../utils/permissions/permissionsLoader.js'
import { logUnaryEvent } from '../../utils/unaryLogging.js'
import { PermissionDialog } from '../../components/permissions/PermissionDialog.js'
import {
PermissionPrompt,
type PermissionPromptOption,
} from '../../components/permissions/PermissionPrompt.js'
import type { PermissionRequestProps } from '../../components/permissions/PermissionRequest.js'
import { PermissionRuleExplanation } from '../../components/permissions/PermissionRuleExplanation.js'
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no'
/**
* Permission request UI for the WorkflowTool. Asks the user to confirm
* executing a workflow script.
* Follows the MonitorPermissionRequest / FallbackPermissionRequest pattern.
*/
export function WorkflowPermissionRequest({
toolUseConfirm,
onDone,
onReject,
workerBadge,
}: PermissionRequestProps): React.ReactNode {
const [themeName] = useTheme()
const theme = getTheme(themeName)
const input = toolUseConfirm.input as {
workflow: string
args?: string
}
const showAlwaysAllowOptions = useMemo(
() => shouldShowAlwaysAllowOptions(),
[],
)
const options: PermissionPromptOption<OptionValue>[] = useMemo(() => {
const opts: PermissionPromptOption<OptionValue>[] = [
{
label: 'Yes',
value: 'yes',
feedbackConfig: { type: 'accept' as const },
},
]
if (showAlwaysAllowOptions) {
opts.push({
label: (
<Text>
Yes, and don{'\u2019'}t ask again for{' '}
<Text bold>{toolUseConfirm.tool.name}</Text> commands
</Text>
),
value: 'yes-dont-ask-again',
})
}
opts.push({
label: 'No',
value: 'no',
feedbackConfig: { type: 'reject' as const },
})
return opts
}, [showAlwaysAllowOptions, toolUseConfirm.tool.name])
const handleSelect = useCallback(
(value: OptionValue, feedback?: string) => {
switch (value) {
case 'yes':
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
})
toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback)
onDone()
break
case 'yes-dont-ask-again':
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
})
toolUseConfirm.onAllow(toolUseConfirm.input, [
{
type: 'addRules',
rules: [{ toolName: toolUseConfirm.tool.name }],
behavior: 'allow',
destination: 'localSettings',
},
])
onDone()
break
case 'no':
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
})
toolUseConfirm.onReject(feedback)
onReject()
onDone()
break
}
},
[toolUseConfirm, onDone, onReject],
)
const handleCancel = useCallback(() => {
logUnaryEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id ?? '',
platform: env.platform,
},
})
toolUseConfirm.onReject()
onReject()
onDone()
}, [toolUseConfirm, onDone, onReject])
return (
<PermissionDialog
title="Workflow"
workerBadge={workerBadge}
>
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text bold color={theme.permission as any}>
Execute workflow: {input.workflow}
</Text>
{input.args && (
<Text dimColor>
Arguments: {input.args}
</Text>
)}
</Box>
<PermissionRuleExplanation
permissionResult={toolUseConfirm.permissionResult}
toolType="command"
/>
<PermissionPrompt<OptionValue>
options={options}
onSelect={handleSelect}
onCancel={handleCancel}
/>
</Box>
</PermissionDialog>
)
}

View File

@@ -1,3 +1,74 @@
// Auto-generated stub — replace with real implementation
export {};
export const WorkflowTool: Record<string, unknown> = {};
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { truncate } from '../../utils/format.js'
import { WORKFLOW_TOOL_NAME } from './constants.js'
const inputSchema = z.object({
workflow: z.string().describe('Name of the workflow to execute'),
args: z.string().optional().describe('Arguments to pass to the workflow'),
})
type Input = typeof inputSchema
type WorkflowInput = z.infer<Input>
type WorkflowOutput = { output: string }
export const WorkflowTool = buildTool({
name: WORKFLOW_TOOL_NAME,
searchHint: 'execute user-defined workflow scripts',
maxResultSizeChars: 50_000,
strict: true,
inputSchema,
async description() {
return 'Execute a user-defined workflow script from .claude/workflows/'
},
async prompt() {
return `Use the Workflow tool to execute user-defined workflow scripts located in .claude/workflows/. Workflows are YAML or Markdown files that define a sequence of steps for common development tasks.
Guidelines:
- Specify the workflow name to execute (must match a file in .claude/workflows/)
- Optionally pass arguments that the workflow can use
- Workflows run in the context of the current project`
},
userFacingName() {
return 'Workflow'
},
isReadOnly() {
return false
},
isEnabled() {
return true
},
renderToolUseMessage(input: Partial<WorkflowInput>) {
const name = input.workflow ?? 'unknown'
if (input.args) {
return `Workflow: ${name} ${input.args}`
}
return `Workflow: ${name}`
},
mapToolResultToToolResultBlockParam(
content: WorkflowOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: truncate(content.output, 50_000),
}
},
async call(_input: WorkflowInput, _context, _progress) {
// Workflow execution is wired by the WORKFLOW_SCRIPTS feature bootstrap.
// Without it, this tool is not functional.
return {
data: {
output:
'Error: Workflow execution requires the WORKFLOW_SCRIPTS runtime.',
},
}
},
})

View File

@@ -0,0 +1,15 @@
// Bundled workflow initialization.
// Called by tools.ts when WORKFLOW_SCRIPTS feature flag is enabled.
// Sets up any pre-bundled workflow scripts that ship with the CLI.
/**
* Initialize bundled workflows. Called once at startup when the
* WORKFLOW_SCRIPTS feature flag is active. This is the hook point
* for registering any workflow scripts that are compiled into the
* binary (as opposed to user-authored ones in .claude/workflows/).
*/
export function initBundledWorkflows(): void {
// Bundled workflows are registered here at startup.
// Currently a no-op — all workflows are user-authored in .claude/workflows/.
// This function exists as the extension point for future built-in workflows.
}

View File

@@ -1,2 +1,3 @@
// Auto-generated stub — replace with real implementation
export const WORKFLOW_TOOL_NAME: string = '';
export const WORKFLOW_TOOL_NAME = 'workflow'
export const WORKFLOW_DIR_NAME = '.claude/workflows'
export const WORKFLOW_FILE_EXTENSIONS = ['.yml', '.yaml', '.md']

View File

@@ -1,3 +1,41 @@
// Auto-generated stub — replace with real implementation
export {};
export const getWorkflowCommands: (...args: unknown[]) => unknown = () => {};
import { readdir } from 'fs/promises'
import { join, parse } from 'path'
import type { Command } from '../../types/command.js'
import { WORKFLOW_DIR_NAME, WORKFLOW_FILE_EXTENSIONS } from './constants.js'
/**
* Scans .claude/workflows/ directory and creates Command objects for each workflow file.
* Each workflow file becomes a slash command (e.g. /workflow-name).
*/
export async function getWorkflowCommands(cwd: string): Promise<Command[]> {
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
let files: string[]
try {
files = await readdir(workflowDir)
} catch {
return []
}
const workflowFiles = files.filter((f) => {
const ext = parse(f).ext.toLowerCase()
return WORKFLOW_FILE_EXTENSIONS.includes(ext)
})
return workflowFiles.map((file) => {
const name = parse(file).name
return {
type: 'prompt' as const,
name,
description: `Run workflow: ${name}`,
kind: 'workflow' as const,
source: 'builtin' as const,
progressMessage: `Running workflow ${name}...`,
contentLength: 0,
async getPromptForCommand(args, _context) {
const { readFile } = await import('fs/promises')
const content = await readFile(join(workflowDir, file), 'utf-8')
return [{ type: 'text' as const, text: `Execute this workflow:\n\n${content}${args ? `\n\nArguments: ${args}` : ''}` }]
},
} satisfies Command
})
}