mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
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:
@@ -2,6 +2,7 @@ import type {
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlockParam,
|
||||
} from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
export type { ToolResultBlockParam }
|
||||
import type {
|
||||
ElicitRequestURLParams,
|
||||
ElicitResult,
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const isKairosEnabled: () => Promise<boolean> = () => Promise.resolve(false);
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getKairosActive } from '../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
|
||||
/**
|
||||
* Runtime gate for KAIROS features.
|
||||
*
|
||||
* Build-time: feature('KAIROS') must be on (checked by caller before
|
||||
* this module is required).
|
||||
*
|
||||
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
|
||||
* switch, and kairosActive state must be true (set during bootstrap when
|
||||
* the session qualifies for KAIROS features).
|
||||
*/
|
||||
export async function isKairosEnabled(): Promise<boolean> {
|
||||
if (!feature('KAIROS')) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return getKairosActive()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const isAssistantMode: () => boolean = () => false;
|
||||
export const initializeAssistantTeam: () => Promise<void> = async () => {};
|
||||
export const markAssistantForced: () => void = () => {};
|
||||
export const isAssistantForced: () => boolean = () => false;
|
||||
export const getAssistantSystemPromptAddendum: () => string = () => '';
|
||||
export const getAssistantActivationPath: () => string | undefined = () => undefined;
|
||||
export {}
|
||||
export const isAssistantMode: () => boolean = () => false
|
||||
export const initializeAssistantTeam: () => Promise<void> = async () => {}
|
||||
export const markAssistantForced: () => void = () => {}
|
||||
export const isAssistantForced: () => boolean = () => false
|
||||
export const getAssistantSystemPromptAddendum: () => string = () => ''
|
||||
export const getAssistantActivationPath: () => string | undefined = () =>
|
||||
undefined
|
||||
|
||||
@@ -80,6 +80,12 @@ const remoteControlServerCommand =
|
||||
const voiceCommand = feature('VOICE_MODE')
|
||||
? require('./commands/voice/index.js').default
|
||||
: null
|
||||
const monitorCmd = feature('MONITOR_TOOL')
|
||||
? require('./commands/monitor.js').default
|
||||
: null
|
||||
const coordinatorCmd = feature('COORDINATOR_MODE')
|
||||
? require('./commands/coordinator.js').default
|
||||
: null
|
||||
const forceSnip = feature('HISTORY_SNIP')
|
||||
? require('./commands/force-snip.js').default
|
||||
: null
|
||||
@@ -110,6 +116,27 @@ const peersCmd = feature('UDS_INBOX')
|
||||
require('./commands/peers/index.js') as typeof import('./commands/peers/index.js')
|
||||
).default
|
||||
: null
|
||||
const attachCmd = feature('UDS_INBOX')
|
||||
? require('./commands/attach/index.js').default
|
||||
: null
|
||||
const detachCmd = feature('UDS_INBOX')
|
||||
? require('./commands/detach/index.js').default
|
||||
: null
|
||||
const sendCmd = feature('UDS_INBOX')
|
||||
? require('./commands/send/index.js').default
|
||||
: null
|
||||
const pipesCmd = feature('UDS_INBOX')
|
||||
? require('./commands/pipes/index.js').default
|
||||
: null
|
||||
const pipeStatusCmd = feature('UDS_INBOX')
|
||||
? require('./commands/pipe-status/index.js').default
|
||||
: null
|
||||
const historyCmd = feature('UDS_INBOX')
|
||||
? require('./commands/history/index.js').default
|
||||
: null
|
||||
const claimMainCmd = feature('UDS_INBOX')
|
||||
? require('./commands/claim-main/index.js').default
|
||||
: null
|
||||
const forkCmd = feature('FORK_SUBAGENT')
|
||||
? (
|
||||
require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
|
||||
@@ -328,6 +355,8 @@ const COMMANDS = memoize((): Command[] => [
|
||||
...(buddy ? [buddy] : []),
|
||||
...(poor ? [poor] : []),
|
||||
...(proactive ? [proactive] : []),
|
||||
...(monitorCmd ? [monitorCmd] : []),
|
||||
...(coordinatorCmd ? [coordinatorCmd] : []),
|
||||
...(briefCommand ? [briefCommand] : []),
|
||||
...(assistantCommand ? [assistantCommand] : []),
|
||||
...(bridge ? [bridge] : []),
|
||||
@@ -344,6 +373,13 @@ const COMMANDS = memoize((): Command[] => [
|
||||
...(!isUsing3PServices() ? [logout, login()] : []),
|
||||
passes,
|
||||
...(peersCmd ? [peersCmd] : []),
|
||||
...(attachCmd ? [attachCmd] : []),
|
||||
...(detachCmd ? [detachCmd] : []),
|
||||
...(sendCmd ? [sendCmd] : []),
|
||||
...(pipesCmd ? [pipesCmd] : []),
|
||||
...(pipeStatusCmd ? [pipeStatusCmd] : []),
|
||||
...(historyCmd ? [historyCmd] : []),
|
||||
...(claimMainCmd ? [claimMainCmd] : []),
|
||||
tasks,
|
||||
...(workflowsCmd ? [workflowsCmd] : []),
|
||||
...(ultraplan ? [ultraplan] : []),
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type React from 'react';
|
||||
import * as React from 'react'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
|
||||
export {};
|
||||
export const NewInstallWizard: React.FC<{
|
||||
defaultDir: string;
|
||||
onInstalled: (dir: string) => void;
|
||||
onCancel: () => void;
|
||||
onError: (message: string) => void;
|
||||
}> = (() => null);
|
||||
export const computeDefaultInstallDir: () => Promise<string> = (() => Promise.resolve(''));
|
||||
/** Stub — install wizard is not yet restored. */
|
||||
export async function computeDefaultInstallDir(): Promise<string> {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Stub — install wizard is not yet restored. */
|
||||
export function NewInstallWizard(_props: {
|
||||
defaultDir: string
|
||||
onInstalled: (dir: string) => void
|
||||
onCancel: () => void
|
||||
onError: (message: string) => void
|
||||
}): React.ReactNode {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* /assistant command implementation.
|
||||
*
|
||||
* Opens the Kairos assistant panel. In the current build the panel is
|
||||
* rendered by the REPL layer when kairosActive is true; the slash command
|
||||
* simply toggles visibility and prints a confirmation line.
|
||||
*/
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
_args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const { setAppState, getAppState } = context
|
||||
|
||||
const current = getAppState()
|
||||
const isVisible = (current as Record<string, unknown>).assistantPanelVisible
|
||||
|
||||
if (isVisible) {
|
||||
setAppState((prev: AppState) => ({
|
||||
...prev,
|
||||
assistantPanelVisible: false,
|
||||
} as AppState))
|
||||
onDone('Assistant panel hidden.', { display: 'system' })
|
||||
} else {
|
||||
setAppState((prev: AppState) => ({
|
||||
...prev,
|
||||
assistantPanelVisible: true,
|
||||
} as AppState))
|
||||
onDone('Assistant panel opened.', { display: 'system' })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
25
src/commands/assistant/gate.ts
Normal file
25
src/commands/assistant/gate.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getKairosActive } from '../../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
|
||||
/**
|
||||
* Runtime gate for the /assistant command.
|
||||
*
|
||||
* Build-time: feature('KAIROS') must be on (checked in commands.ts before
|
||||
* the module is even required).
|
||||
*
|
||||
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
|
||||
* switch, and kairosActive state must be true (set during bootstrap when
|
||||
* the session qualifies for KAIROS features).
|
||||
*/
|
||||
export function isAssistantEnabled(): boolean {
|
||||
if (!feature('KAIROS')) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return getKairosActive()
|
||||
}
|
||||
16
src/commands/assistant/index.ts
Normal file
16
src/commands/assistant/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isAssistantEnabled } from './gate.js'
|
||||
|
||||
const assistant = {
|
||||
type: 'local-jsx',
|
||||
name: 'assistant',
|
||||
description: 'Open the Kairos assistant panel',
|
||||
isEnabled: isAssistantEnabled,
|
||||
get isHidden() {
|
||||
return !isAssistantEnabled()
|
||||
},
|
||||
immediate: true,
|
||||
load: () => import('./assistant.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default assistant
|
||||
137
src/commands/attach/attach.ts
Normal file
137
src/commands/attach/attach.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import {
|
||||
connectToPipe,
|
||||
getPipeIpc,
|
||||
isPipeControlled,
|
||||
type PipeClient,
|
||||
type PipeMessage,
|
||||
type TcpEndpoint,
|
||||
} from '../../utils/pipeTransport.js'
|
||||
import { addSlaveClient } from '../../hooks/useMasterMonitor.js'
|
||||
|
||||
export const call: LocalCommandCall = async (args, context) => {
|
||||
const targetName = args.trim()
|
||||
if (!targetName) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Usage: /attach <pipe-name>\nUse /pipes to list available pipes.',
|
||||
}
|
||||
}
|
||||
|
||||
const currentState = context.getAppState()
|
||||
|
||||
// Check if already attached to this slave
|
||||
if (getPipeIpc(currentState).slaves[targetName]) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Already attached to "${targetName}".`,
|
||||
}
|
||||
}
|
||||
|
||||
// Controlled sub sessions cannot attach to other sub sessions.
|
||||
if (isPipeControlled(getPipeIpc(currentState))) {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Cannot attach: this sub is currently controlled by a master. Detach it from the master first.',
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve TCP endpoint for LAN peers
|
||||
let tcpEndpoint: TcpEndpoint | undefined
|
||||
if (feature('LAN_PIPES')) {
|
||||
const pipeState = getPipeIpc(currentState)
|
||||
const discoveredPeer = pipeState.discoveredPipes.find(
|
||||
(p: { pipeName: string }) => p.pipeName === targetName,
|
||||
)
|
||||
if (discoveredPeer) {
|
||||
// Check if this is a LAN peer by looking up beacon data
|
||||
const { getLanBeacon } =
|
||||
require('../../utils/lanBeacon.js') as typeof import('../../utils/lanBeacon.js')
|
||||
const beaconRef = getLanBeacon()
|
||||
if (beaconRef) {
|
||||
const lanPeers = beaconRef.getPeers()
|
||||
const lanPeer = lanPeers.get(targetName)
|
||||
if (lanPeer) {
|
||||
tcpEndpoint = { host: lanPeer.ip, port: lanPeer.tcpPort }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to the target pipe server (UDS or TCP)
|
||||
let client: PipeClient
|
||||
try {
|
||||
const myName =
|
||||
getPipeIpc(currentState).serverName ?? `master-${process.pid}`
|
||||
client = await connectToPipe(targetName, myName, undefined, tcpEndpoint)
|
||||
} catch (err) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Failed to connect to "${targetName}"${tcpEndpoint ? ` (TCP ${tcpEndpoint.host}:${tcpEndpoint.port})` : ''}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Send attach request and wait for response
|
||||
return new Promise(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.disconnect()
|
||||
resolve({
|
||||
type: 'text',
|
||||
value: `Attach to "${targetName}" timed out (no response within 5s).`,
|
||||
})
|
||||
}, 5000)
|
||||
|
||||
client.onMessage((msg: PipeMessage) => {
|
||||
if (msg.type === 'attach_accept') {
|
||||
clearTimeout(timeout)
|
||||
|
||||
// Register the slave client in the module-level registry
|
||||
addSlaveClient(targetName, client)
|
||||
|
||||
// Update AppState: add slave and switch to master role
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...getPipeIpc(prev),
|
||||
role: 'master',
|
||||
displayRole: 'master',
|
||||
slaves: {
|
||||
...getPipeIpc(prev).slaves,
|
||||
[targetName]: {
|
||||
name: targetName,
|
||||
connectedAt: new Date().toISOString(),
|
||||
status: 'idle' as const,
|
||||
unreadCount: 0,
|
||||
history: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const slaveCount =
|
||||
Object.keys(getPipeIpc(currentState).slaves).length + 1
|
||||
resolve({
|
||||
type: 'text',
|
||||
value: `Attached to "${targetName}" as master. Now monitoring ${slaveCount} sub session(s).\nUse /send ${targetName} <message> to send tasks.\nUse /status to see all connected subs.\nUse /detach ${targetName} to disconnect.`,
|
||||
})
|
||||
} else if (msg.type === 'attach_reject') {
|
||||
clearTimeout(timeout)
|
||||
client.disconnect()
|
||||
|
||||
resolve({
|
||||
type: 'text',
|
||||
value: `Attach rejected by "${targetName}": ${msg.data ?? 'unknown reason'}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Include machineId so remote can distinguish LAN peers from local peers
|
||||
const pipeState = getPipeIpc(currentState)
|
||||
client.send({
|
||||
type: 'attach_request',
|
||||
meta: { machineId: pipeState.machineId },
|
||||
})
|
||||
})
|
||||
}
|
||||
11
src/commands/attach/index.ts
Normal file
11
src/commands/attach/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const attach = {
|
||||
type: 'local',
|
||||
name: 'attach',
|
||||
description: 'Attach to a sub Claude CLI instance via named pipe',
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./attach.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default attach
|
||||
76
src/commands/claim-main/claim-main.ts
Normal file
76
src/commands/claim-main/claim-main.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { getPipeIpc } from '../../utils/pipeTransport.js'
|
||||
import {
|
||||
getMachineId,
|
||||
getMacAddress,
|
||||
claimMain,
|
||||
readRegistry,
|
||||
} from '../../utils/pipeRegistry.js'
|
||||
import { getLocalIp } from '../../utils/pipeTransport.js'
|
||||
|
||||
export const call: LocalCommandCall = async (_args, context) => {
|
||||
const currentState = context.getAppState()
|
||||
const pipeState = getPipeIpc(currentState)
|
||||
const myName = pipeState.serverName
|
||||
|
||||
if (!myName) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Pipe server not started. Cannot claim main.',
|
||||
}
|
||||
}
|
||||
|
||||
const machineId = await getMachineId()
|
||||
const registry = await readRegistry()
|
||||
|
||||
// Already main machine?
|
||||
if (registry.mainMachineId === machineId && registry.main?.id === myName) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'This instance is already the main. No change needed.',
|
||||
}
|
||||
}
|
||||
|
||||
const { hostname } = require('os') as typeof import('os')
|
||||
|
||||
const entry = {
|
||||
id: myName,
|
||||
pid: process.pid,
|
||||
machineId,
|
||||
startedAt: Date.now(),
|
||||
ip: getLocalIp(),
|
||||
mac: getMacAddress(),
|
||||
hostname: hostname(),
|
||||
pipeName: myName,
|
||||
}
|
||||
|
||||
await claimMain(machineId, entry)
|
||||
|
||||
// Update local state
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...getPipeIpc(prev),
|
||||
role: 'main',
|
||||
subIndex: null,
|
||||
displayRole: 'main',
|
||||
machineId,
|
||||
attachedBy: null,
|
||||
},
|
||||
}))
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('Main role claimed successfully.')
|
||||
lines.push(`Machine ID: ${machineId.slice(0, 8)}...`)
|
||||
lines.push(`Pipe: ${myName}`)
|
||||
if (registry.mainMachineId && registry.mainMachineId !== machineId) {
|
||||
lines.push(
|
||||
`Previous main machine: ${registry.mainMachineId.slice(0, 8)}...`,
|
||||
)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('All existing subs are now bound to this instance.')
|
||||
lines.push('Use /pipes to verify.')
|
||||
|
||||
return { type: 'text', value: lines.join('\n') }
|
||||
}
|
||||
12
src/commands/claim-main/index.ts
Normal file
12
src/commands/claim-main/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const claimMain = {
|
||||
type: 'local',
|
||||
name: 'claim-main',
|
||||
description:
|
||||
'Claim main role for this machine (overrides current main machine)',
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./claim-main.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default claimMain
|
||||
63
src/commands/coordinator.ts
Normal file
63
src/commands/coordinator.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* /coordinator — Toggle coordinator (multi-worker orchestration) mode.
|
||||
*
|
||||
* When enabled, the CLI becomes an orchestrator that dispatches tasks
|
||||
* to worker agents via Agent({ subagent_type: "worker" }).
|
||||
* The coordinator can only use Agent, SendMessage, and TaskStop.
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
import type {
|
||||
Command,
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../types/command.js'
|
||||
|
||||
const coordinator = {
|
||||
type: 'local-jsx',
|
||||
name: 'coordinator',
|
||||
description: 'Toggle coordinator (multi-worker) mode',
|
||||
isEnabled: () => {
|
||||
if (feature('COORDINATOR_MODE')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
immediate: true,
|
||||
load: () =>
|
||||
Promise.resolve({
|
||||
async call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: ToolUseContext & LocalJSXCommandContext,
|
||||
): Promise<React.ReactNode> {
|
||||
const mod =
|
||||
require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js')
|
||||
|
||||
if (mod.isCoordinatorMode()) {
|
||||
// Disable: clear the env var
|
||||
delete process.env.CLAUDE_CODE_COORDINATOR_MODE
|
||||
onDone('Coordinator mode disabled — back to normal mode', {
|
||||
display: 'system',
|
||||
metaMessages: [
|
||||
'<system-reminder>\nCoordinator mode is now disabled. You have access to all standard tools again. Work directly instead of dispatching to workers.\n</system-reminder>',
|
||||
],
|
||||
})
|
||||
} else {
|
||||
// Enable: set the env var
|
||||
process.env.CLAUDE_CODE_COORDINATOR_MODE = '1'
|
||||
onDone(
|
||||
'Coordinator mode enabled — use Agent(subagent_type: "worker") to dispatch tasks',
|
||||
{
|
||||
display: 'system',
|
||||
metaMessages: [
|
||||
'<system-reminder>\nCoordinator mode is now enabled. You are an orchestrator. Use Agent({ subagent_type: "worker" }) to spawn workers, SendMessage to continue them, TaskStop to stop them. Do not use other tools directly.\n</system-reminder>',
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
}),
|
||||
} satisfies Command
|
||||
|
||||
export default coordinator
|
||||
95
src/commands/detach/detach.ts
Normal file
95
src/commands/detach/detach.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import {
|
||||
removeSlaveClient,
|
||||
getAllSlaveClients,
|
||||
} from '../../hooks/useMasterMonitor.js'
|
||||
import { getPipeIpc, isPipeControlled } from '../../utils/pipeTransport.js'
|
||||
|
||||
export const call: LocalCommandCall = async (args, context) => {
|
||||
const currentState = context.getAppState()
|
||||
|
||||
if (getPipeIpc(currentState).role === 'main') {
|
||||
return { type: 'text', value: 'Not attached to any CLI.' }
|
||||
}
|
||||
|
||||
if (isPipeControlled(getPipeIpc(currentState))) {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'This sub session is controlled by a master. The master must detach.',
|
||||
}
|
||||
}
|
||||
|
||||
// Master mode
|
||||
const targetName = args.trim()
|
||||
|
||||
if (targetName) {
|
||||
// Detach from a specific slave
|
||||
const client = removeSlaveClient(targetName)
|
||||
if (!client) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Not attached to "${targetName}". Use /status to see connected sub sessions.`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
client.send({ type: 'detach' })
|
||||
} catch {
|
||||
// Socket may already be closed
|
||||
}
|
||||
client.disconnect()
|
||||
|
||||
// Remove slave from state
|
||||
context.setAppState(prev => {
|
||||
const { [targetName]: _removed, ...remainingSlaves } =
|
||||
getPipeIpc(prev).slaves
|
||||
const hasSlaves = Object.keys(remainingSlaves).length > 0
|
||||
return {
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...getPipeIpc(prev),
|
||||
role: hasSlaves ? 'master' : 'main',
|
||||
displayRole: hasSlaves ? 'master' : 'main',
|
||||
slaves: remainingSlaves,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Detached from "${targetName}".`,
|
||||
}
|
||||
}
|
||||
|
||||
// No target specified — detach from ALL slaves
|
||||
const allClients = getAllSlaveClients()
|
||||
const slaveNames = Array.from(allClients.keys())
|
||||
|
||||
for (const name of slaveNames) {
|
||||
const client = removeSlaveClient(name)
|
||||
if (client) {
|
||||
try {
|
||||
client.send({ type: 'detach' })
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
client.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...getPipeIpc(prev),
|
||||
role: 'main',
|
||||
displayRole: 'main',
|
||||
slaves: {},
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Detached from ${slaveNames.length} sub session(s): ${slaveNames.join(', ')}. Back to main mode.`,
|
||||
}
|
||||
}
|
||||
11
src/commands/detach/index.ts
Normal file
11
src/commands/detach/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const detach = {
|
||||
type: 'local',
|
||||
name: 'detach',
|
||||
description: 'Detach from a sub CLI (or all connected subs)',
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./detach.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default detach
|
||||
59
src/commands/force-snip.ts
Normal file
59
src/commands/force-snip.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Command, LocalCommandCall } from '../types/command.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
|
||||
/**
|
||||
* Insert a snip boundary into the message array.
|
||||
*
|
||||
* A snip boundary is a system message that marks everything before it as
|
||||
* "snipped". During the next query cycle, `snipCompactIfNeeded` (in
|
||||
* services/compact/snipCompact.ts) detects this boundary and removes — or
|
||||
* collapses — the older messages so they no longer consume context-window
|
||||
* tokens. The REPL keeps the full history for UI scrollback; the boundary
|
||||
* only affects model-facing projections.
|
||||
*
|
||||
* The `snipMetadata.removedUuids` field tells downstream consumers
|
||||
* (sessionStorage persistence, snipProjection) which messages were removed.
|
||||
*/
|
||||
const call: LocalCommandCall = async (_args, context) => {
|
||||
const { messages, setMessages } = context
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { type: 'text', value: 'No messages to snip.' }
|
||||
}
|
||||
|
||||
// Collect UUIDs of every message that will be snipped (everything currently
|
||||
// in the conversation). The next call to `snipCompactIfNeeded` will honour
|
||||
// the boundary and strip these from the model-facing view.
|
||||
const removedUuids = messages.map((m) => m.uuid)
|
||||
|
||||
const boundaryMessage: Message = {
|
||||
type: 'system',
|
||||
subtype: 'snip_boundary',
|
||||
content: '[snip] Conversation history before this point has been snipped.',
|
||||
isMeta: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
uuid: randomUUID(),
|
||||
snipMetadata: {
|
||||
removedUuids,
|
||||
},
|
||||
} as Message // subtype is feature-gated; cast through Message
|
||||
|
||||
setMessages((prev) => [...prev, boundaryMessage])
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Snipped ${removedUuids.length} message(s). Older history will be excluded from the next model query.`,
|
||||
}
|
||||
}
|
||||
|
||||
const forceSnip = {
|
||||
type: 'local',
|
||||
name: 'force-snip',
|
||||
description: 'Force snip conversation history at current point',
|
||||
supportsNonInteractive: true,
|
||||
isHidden: true,
|
||||
load: () => Promise.resolve({ call }),
|
||||
} satisfies Command
|
||||
|
||||
export default forceSnip
|
||||
93
src/commands/history/history.ts
Normal file
93
src/commands/history/history.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { getPipeIpc } from '../../utils/pipeTransport.js'
|
||||
|
||||
export const call: LocalCommandCall = async (args, context) => {
|
||||
const currentState = context.getAppState()
|
||||
|
||||
if (getPipeIpc(currentState).role !== 'master') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Not in master mode. Use /attach <pipe-name> first.',
|
||||
}
|
||||
}
|
||||
|
||||
const parts = args.trim().split(/\s+/)
|
||||
const targetName = parts[0]
|
||||
|
||||
if (!targetName) {
|
||||
// Show list of connected sub sessions
|
||||
const slaveNames = Object.keys(getPipeIpc(currentState).slaves)
|
||||
if (slaveNames.length === 0) {
|
||||
return { type: 'text', value: 'No sub sessions connected.' }
|
||||
}
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Usage: /history <pipe-name>\nConnected sub sessions: ${slaveNames.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
const slave = getPipeIpc(currentState).slaves[targetName]
|
||||
if (!slave) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Not attached to "${targetName}". Use /status to see connected sub sessions.`,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse --last N
|
||||
let limit = slave.history.length
|
||||
const lastIdx = parts.indexOf('--last')
|
||||
if (lastIdx !== -1 && parts[lastIdx + 1]) {
|
||||
const n = parseInt(parts[lastIdx + 1], 10)
|
||||
if (!isNaN(n) && n > 0) {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
const entries = slave.history.slice(-limit)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `No session history for "${targetName}" yet.`,
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`Session history for "${targetName}" (${entries.length}/${slave.history.length} entries):`,
|
||||
'',
|
||||
]
|
||||
|
||||
for (const entry of entries) {
|
||||
const time = entry.timestamp.slice(11, 19) // HH:MM:SS
|
||||
const prefix = formatEntryType(entry.type)
|
||||
const content =
|
||||
entry.content.length > 200
|
||||
? entry.content.slice(0, 200) + '...'
|
||||
: entry.content
|
||||
lines.push(`[${time}] ${prefix} ${content}`)
|
||||
}
|
||||
|
||||
return { type: 'text', value: lines.join('\n') }
|
||||
}
|
||||
|
||||
function formatEntryType(type: string): string {
|
||||
switch (type) {
|
||||
case 'prompt':
|
||||
return '[PROMPT]'
|
||||
case 'prompt_ack':
|
||||
return '[ACK] '
|
||||
case 'stream':
|
||||
return '[AI] '
|
||||
case 'tool_start':
|
||||
return '[TOOL>] '
|
||||
case 'tool_result':
|
||||
return '[TOOL<] '
|
||||
case 'done':
|
||||
return '[DONE] '
|
||||
case 'error':
|
||||
return '[ERROR] '
|
||||
default:
|
||||
return `[${type}]`
|
||||
}
|
||||
}
|
||||
12
src/commands/history/index.ts
Normal file
12
src/commands/history/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const history = {
|
||||
type: 'local',
|
||||
name: 'history',
|
||||
aliases: ['hist'],
|
||||
description: 'View session history of a connected sub CLI',
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./history.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default history
|
||||
108
src/commands/monitor.ts
Normal file
108
src/commands/monitor.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* /monitor <command> — Start a background monitor task.
|
||||
*
|
||||
* Shortcut for the MonitorTool. Spawns a long-running shell command
|
||||
* as a background task visible in the footer pill (Shift+Down to view).
|
||||
*
|
||||
* Usage:
|
||||
* /monitor tail -f /var/log/syslog
|
||||
* /monitor watch -n 5 git status
|
||||
* /monitor "while true; do curl -s http://localhost:3000/health; sleep 10; done"
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import type {
|
||||
Command,
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../types/command.js'
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
|
||||
const monitor = {
|
||||
type: 'local-jsx',
|
||||
name: 'monitor',
|
||||
description: 'Start a background shell monitor (Shift+Down to view)',
|
||||
isEnabled: () => {
|
||||
if (feature('MONITOR_TOOL')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
immediate: false,
|
||||
userFacingName: () => 'monitor',
|
||||
load: () =>
|
||||
Promise.resolve({
|
||||
async call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: ToolUseContext & LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
let command = args.trim()
|
||||
if (!command) {
|
||||
onDone(
|
||||
process.platform === 'win32'
|
||||
? 'Usage: /monitor <command>\nExample: /monitor powershell -c "while(1){git status; Start-Sleep 5}"'
|
||||
: 'Usage: /monitor <command>\nExample: /monitor watch -n 5 git status',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Windows compatibility: convert `watch -n <sec> <cmd>` to a PowerShell loop
|
||||
if (process.platform === 'win32') {
|
||||
const watchMatch = command.match(/^watch\s+-n\s+(\d+)\s+(.+)$/)
|
||||
if (watchMatch) {
|
||||
const interval = watchMatch[1]
|
||||
const innerCmd = watchMatch[2]
|
||||
command = `powershell -c "while(1){${innerCmd}; Start-Sleep ${interval}}"`
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic require to stay behind feature gate
|
||||
const { spawnShellTask } =
|
||||
require('../tasks/LocalShellTask/LocalShellTask.js') as typeof import('../tasks/LocalShellTask/LocalShellTask.js')
|
||||
const { exec } =
|
||||
require('../utils/Shell.js') as typeof import('../utils/Shell.js')
|
||||
const { getTaskOutputPath } =
|
||||
require('../utils/task/diskOutput.js') as typeof import('../utils/task/diskOutput.js')
|
||||
|
||||
try {
|
||||
const shellCommand = await exec(
|
||||
command,
|
||||
context.abortController.signal,
|
||||
'bash',
|
||||
)
|
||||
|
||||
const handle = await spawnShellTask(
|
||||
{
|
||||
command,
|
||||
description: command,
|
||||
shellCommand,
|
||||
toolUseId: context.toolUseId ?? `monitor-${Date.now()}`,
|
||||
agentId: undefined,
|
||||
kind: 'monitor',
|
||||
},
|
||||
{
|
||||
abortController: context.abortController,
|
||||
getAppState: context.getAppState,
|
||||
setAppState: context.setAppState,
|
||||
},
|
||||
)
|
||||
|
||||
const outputFile = getTaskOutputPath(handle.taskId)
|
||||
onDone(
|
||||
`Monitor started (${handle.taskId}). Press Shift+Down to view.\nOutput: ${outputFile}`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
} catch (err) {
|
||||
onDone(
|
||||
`Monitor failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
}),
|
||||
} satisfies Command
|
||||
|
||||
export default monitor
|
||||
@@ -1,3 +1,12 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
const _default: Record<string, unknown> = {};
|
||||
export default _default;
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const peers = {
|
||||
type: 'local',
|
||||
name: 'peers',
|
||||
aliases: ['who'],
|
||||
description: 'List connected Claude Code peers',
|
||||
supportsNonInteractive: true,
|
||||
load: () => import('./peers.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default peers
|
||||
|
||||
61
src/commands/peers/peers.ts
Normal file
61
src/commands/peers/peers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { listPeers, isPeerAlive } from '../../utils/udsClient.js'
|
||||
import { getUdsMessagingSocketPath } from '../../utils/udsMessaging.js'
|
||||
|
||||
export const call: LocalCommandCall = async (_args, _context) => {
|
||||
const mySocket = getUdsMessagingSocketPath()
|
||||
const peers = await listPeers()
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
// Show own socket
|
||||
lines.push(`Your socket: ${mySocket ?? '(not started)'}`)
|
||||
lines.push('')
|
||||
|
||||
if (peers.length === 0) {
|
||||
lines.push('No other Claude Code peers found.')
|
||||
} else {
|
||||
lines.push(`Peers (${peers.length}):`)
|
||||
lines.push('')
|
||||
|
||||
for (const peer of peers) {
|
||||
const alive = peer.messagingSocketPath
|
||||
? await isPeerAlive(peer.messagingSocketPath)
|
||||
: false
|
||||
const status = alive ? 'reachable' : 'unreachable'
|
||||
const label = peer.name ?? peer.kind ?? 'interactive'
|
||||
const cwd = peer.cwd ? ` cwd: ${peer.cwd}` : ''
|
||||
const age = peer.startedAt
|
||||
? ` started: ${formatAge(peer.startedAt)}`
|
||||
: ''
|
||||
|
||||
lines.push(
|
||||
` [${status}] PID ${peer.pid} (${label})${cwd}${age}`,
|
||||
)
|
||||
if (peer.messagingSocketPath) {
|
||||
lines.push(` socket: ${peer.messagingSocketPath}`)
|
||||
}
|
||||
if (peer.sessionId) {
|
||||
lines.push(` session: ${peer.sessionId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
lines.push(
|
||||
'To message a peer: use SendMessage with to="uds:<socket-path>"',
|
||||
)
|
||||
|
||||
return { type: 'text', value: lines.join('\n') }
|
||||
}
|
||||
|
||||
function formatAge(startedAt: number): string {
|
||||
const elapsed = Date.now() - startedAt
|
||||
const seconds = Math.floor(elapsed / 1000)
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainingMinutes = minutes % 60
|
||||
return `${hours}h ${remainingMinutes}m ago`
|
||||
}
|
||||
11
src/commands/pipe-status/index.ts
Normal file
11
src/commands/pipe-status/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const pipeStatus = {
|
||||
type: 'local',
|
||||
name: 'pipe-status',
|
||||
description: 'Show current pipe connection status',
|
||||
supportsNonInteractive: true,
|
||||
load: () => import('./pipe-status.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default pipeStatus
|
||||
65
src/commands/pipe-status/pipe-status.ts
Normal file
65
src/commands/pipe-status/pipe-status.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { getAllSlaveClients } from '../../hooks/useMasterMonitor.js'
|
||||
import {
|
||||
getPipeDisplayRole,
|
||||
getPipeIpc,
|
||||
isPipeControlled,
|
||||
} from '../../utils/pipeTransport.js'
|
||||
|
||||
export const call: LocalCommandCall = async (_args, context) => {
|
||||
const currentState = context.getAppState()
|
||||
|
||||
if (getPipeIpc(currentState).role === 'main') {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Main mode — not connected to any CLIs.\nUse /attach <pipe-name> to connect to a sub session.',
|
||||
}
|
||||
}
|
||||
|
||||
if (isPipeControlled(getPipeIpc(currentState))) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `${getPipeDisplayRole(getPipeIpc(currentState))} mode — controlled by "${getPipeIpc(currentState).attachedBy}".\nAll session data is being reported to the master.`,
|
||||
}
|
||||
}
|
||||
|
||||
// Master mode
|
||||
const slaves = getPipeIpc(currentState).slaves
|
||||
const slaveNames = Object.keys(slaves)
|
||||
const clients = getAllSlaveClients()
|
||||
|
||||
if (slaveNames.length === 0) {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Master mode but no sub sessions connected.\nUse /attach <pipe-name> to connect.',
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`Master mode — ${slaveNames.length} sub session(s) connected:`,
|
||||
'',
|
||||
]
|
||||
|
||||
for (const name of slaveNames) {
|
||||
const slave = slaves[name]!
|
||||
const client = clients.get(name)
|
||||
const connected = client?.connected ? 'connected' : 'disconnected'
|
||||
const historyCount = slave.history.length
|
||||
const connectedAt = slave.connectedAt.slice(11, 19)
|
||||
|
||||
lines.push(` ${name}`)
|
||||
lines.push(` Status: ${slave.status} (${connected})`)
|
||||
lines.push(` Connected: ${connectedAt}`)
|
||||
lines.push(` History: ${historyCount} entries`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('Commands:')
|
||||
lines.push(' /send <name> <msg> — Send a task to a sub session')
|
||||
lines.push(' /history <name> — View sub session transcript')
|
||||
lines.push(' /detach [name] — Disconnect from a sub session (or all)')
|
||||
|
||||
return { type: 'text', value: lines.join('\n') }
|
||||
}
|
||||
11
src/commands/pipes/index.ts
Normal file
11
src/commands/pipes/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const pipes = {
|
||||
type: 'local',
|
||||
name: 'pipes',
|
||||
description: 'Inspect pipe registry state and toggle the pipe selector',
|
||||
supportsNonInteractive: true,
|
||||
load: () => import('./pipes.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default pipes
|
||||
231
src/commands/pipes/pipes.ts
Normal file
231
src/commands/pipes/pipes.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import {
|
||||
isPipeAlive,
|
||||
getPipeIpc,
|
||||
getPipeDisplayRole,
|
||||
isPipeControlled,
|
||||
} from '../../utils/pipeTransport.js'
|
||||
import {
|
||||
cleanupStaleEntries,
|
||||
readRegistry,
|
||||
isMainMachine,
|
||||
mergeWithLanPeers,
|
||||
} from '../../utils/pipeRegistry.js'
|
||||
|
||||
export const call: LocalCommandCall = async (_args, context) => {
|
||||
const args = _args.trim()
|
||||
|
||||
// Enable status line + toggle selector open
|
||||
context.setAppState(prev => {
|
||||
const pipeIpc = getPipeIpc(prev)
|
||||
return {
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...pipeIpc,
|
||||
statusVisible: true,
|
||||
selectorOpen: !pipeIpc.selectorOpen,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Handle select/deselect subcommands
|
||||
if (args.startsWith('select ') || args.startsWith('sel ')) {
|
||||
const pipeName = args.replace(/^(select|sel)\s+/, '').trim()
|
||||
if (!pipeName)
|
||||
return { type: 'text', value: 'Usage: /pipes select <pipe-name>' }
|
||||
context.setAppState(prev => {
|
||||
const pipeIpc = getPipeIpc(prev)
|
||||
const selected = pipeIpc.selectedPipes ?? []
|
||||
if (selected.includes(pipeName)) return prev
|
||||
return {
|
||||
...prev,
|
||||
pipeIpc: { ...pipeIpc, selectedPipes: [...selected, pipeName] },
|
||||
}
|
||||
})
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Selected ${pipeName} — messages will be broadcast to this pipe.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
args.startsWith('deselect ') ||
|
||||
args.startsWith('desel ') ||
|
||||
args.startsWith('unsel ')
|
||||
) {
|
||||
const pipeName = args.replace(/^(deselect|desel|unsel)\s+/, '').trim()
|
||||
if (!pipeName)
|
||||
return { type: 'text', value: 'Usage: /pipes deselect <pipe-name>' }
|
||||
context.setAppState(prev => {
|
||||
const pipeIpc = getPipeIpc(prev)
|
||||
const selected = (pipeIpc.selectedPipes ?? []).filter(
|
||||
(n: string) => n !== pipeName,
|
||||
)
|
||||
return { ...prev, pipeIpc: { ...pipeIpc, selectedPipes: selected } }
|
||||
})
|
||||
return { type: 'text', value: `Deselected ${pipeName}.` }
|
||||
}
|
||||
|
||||
if (args === 'select-all' || args === 'all') {
|
||||
const currentState = context.getAppState()
|
||||
const pipeState = getPipeIpc(currentState)
|
||||
const slaveNames = Object.keys(pipeState.slaves)
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
pipeIpc: { ...getPipeIpc(prev), selectedPipes: slaveNames },
|
||||
}))
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Selected all ${slaveNames.length} connected pipes.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (args === 'deselect-all' || args === 'none') {
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
pipeIpc: { ...getPipeIpc(prev), selectedPipes: [] },
|
||||
}))
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Deselected all pipes. Messages will only run locally.',
|
||||
}
|
||||
}
|
||||
|
||||
const currentState = context.getAppState()
|
||||
const pipeState = getPipeIpc(currentState)
|
||||
const myName = pipeState.serverName
|
||||
const displayRole = getPipeDisplayRole(pipeState)
|
||||
const selected: string[] = pipeState.selectedPipes ?? []
|
||||
|
||||
await cleanupStaleEntries()
|
||||
const registry = await readRegistry()
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(`Your pipe: ${myName ?? '(not started)'}`)
|
||||
lines.push(`Role: ${displayRole}`)
|
||||
if (pipeState.machineId)
|
||||
lines.push(`Machine ID: ${pipeState.machineId.slice(0, 8)}...`)
|
||||
if (pipeState.localIp) lines.push(`IP: ${pipeState.localIp}`)
|
||||
if (pipeState.hostname) lines.push(`Host: ${pipeState.hostname}`)
|
||||
|
||||
if (isPipeControlled(pipeState)) {
|
||||
lines.push(`Controlled by: ${pipeState.attachedBy}`)
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
|
||||
if (registry.mainMachineId) {
|
||||
const isMyMachine = isMainMachine(pipeState.machineId ?? '', registry)
|
||||
lines.push(
|
||||
`Main machine: ${registry.mainMachineId.slice(0, 8)}...${isMyMachine ? ' (this machine)' : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Show main from registry
|
||||
if (registry.main) {
|
||||
const m = registry.main
|
||||
const alive = await isPipeAlive(m.pipeName, 1000)
|
||||
const isSelf = m.pipeName === myName
|
||||
lines.push(
|
||||
` [main] ${m.pipeName} ${m.hostname}/${m.ip} [${alive ? 'alive' : 'stale'}]${isSelf ? ' (you)' : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Show subs from registry with selection status
|
||||
const discoveredPipes: Array<{
|
||||
id: string
|
||||
pipeName: string
|
||||
role: string
|
||||
machineId: string
|
||||
ip: string
|
||||
hostname: string
|
||||
alive: boolean
|
||||
}> = []
|
||||
|
||||
for (const sub of registry.subs) {
|
||||
const alive = await isPipeAlive(sub.pipeName, 1000)
|
||||
const isSelf = sub.pipeName === myName
|
||||
const isSelected = selected.includes(sub.pipeName)
|
||||
const checkbox = isSelected ? '☑' : '☐'
|
||||
const isAttached = pipeState.slaves[sub.pipeName] ? ' [connected]' : ''
|
||||
lines.push(
|
||||
` ${checkbox} [sub-${sub.subIndex}] ${sub.pipeName} ${sub.hostname}/${sub.ip} [${alive ? 'alive' : 'stale'}]${isAttached}${isSelf ? ' (you)' : ''}`,
|
||||
)
|
||||
if (alive) {
|
||||
discoveredPipes.push({
|
||||
id: sub.id,
|
||||
pipeName: sub.pipeName,
|
||||
role: `sub-${sub.subIndex}`,
|
||||
machineId: sub.machineId,
|
||||
ip: sub.ip,
|
||||
hostname: sub.hostname,
|
||||
alive,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!registry.main && registry.subs.length === 0) {
|
||||
lines.push('No other pipes in registry.')
|
||||
}
|
||||
|
||||
// Show LAN peers (if LAN_PIPES enabled)
|
||||
if (feature('LAN_PIPES')) {
|
||||
const { getLanBeacon } =
|
||||
require('../../utils/lanBeacon.js') as typeof import('../../utils/lanBeacon.js')
|
||||
const lanBeaconRef = getLanBeacon()
|
||||
if (lanBeaconRef) {
|
||||
const lanPeers = lanBeaconRef.getPeers()
|
||||
const merged = mergeWithLanPeers(registry, lanPeers)
|
||||
const lanOnly = merged.filter(e => e.source === 'lan')
|
||||
if (lanOnly.length > 0) {
|
||||
lines.push('')
|
||||
lines.push('LAN Peers:')
|
||||
for (const peer of lanOnly) {
|
||||
const isSelected = selected.includes(peer.pipeName)
|
||||
const checkbox = isSelected ? '☑' : '☐'
|
||||
const ep = peer.tcpEndpoint
|
||||
? `tcp:${peer.tcpEndpoint.host}:${peer.tcpEndpoint.port}`
|
||||
: ''
|
||||
lines.push(
|
||||
` ${checkbox} [${peer.role}] ${peer.pipeName} ${peer.hostname}/${peer.ip} ${ep} [LAN]`,
|
||||
)
|
||||
discoveredPipes.push({
|
||||
id: peer.id,
|
||||
pipeName: peer.pipeName,
|
||||
role: peer.role,
|
||||
machineId: peer.machineId,
|
||||
ip: peer.ip,
|
||||
hostname: peer.hostname,
|
||||
alive: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
lines.push('')
|
||||
lines.push('LAN Peers: (none discovered)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
pipeIpc: { ...getPipeIpc(prev), discoveredPipes },
|
||||
}))
|
||||
|
||||
lines.push('')
|
||||
lines.push(
|
||||
`Selected: ${selected.length > 0 ? selected.join(', ') : '(none — messages run locally only)'}`,
|
||||
)
|
||||
lines.push('')
|
||||
lines.push('Commands:')
|
||||
lines.push(' /pipes select <name> — select pipe for broadcast')
|
||||
lines.push(' /pipes deselect <name> — deselect pipe')
|
||||
lines.push(' /pipes all — select all connected')
|
||||
lines.push(' /pipes none — deselect all')
|
||||
lines.push(' /send <name> <msg> — send to specific pipe')
|
||||
lines.push(' /claim-main — claim this machine as main')
|
||||
|
||||
return { type: 'text', value: lines.join('\n') }
|
||||
}
|
||||
56
src/commands/proactive.ts
Normal file
56
src/commands/proactive.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* /proactive — Toggle proactive (autonomous tick-driven) mode.
|
||||
*
|
||||
* When enabled, the model receives periodic <tick> prompts and works
|
||||
* autonomously between user inputs. SleepTool controls pacing.
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
import type {
|
||||
Command,
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../types/command.js'
|
||||
|
||||
const proactive = {
|
||||
type: 'local-jsx',
|
||||
name: 'proactive',
|
||||
description: 'Toggle proactive (autonomous) mode',
|
||||
isEnabled: () => {
|
||||
if (feature('PROACTIVE') || feature('KAIROS')) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
immediate: true,
|
||||
load: () =>
|
||||
Promise.resolve({
|
||||
async call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: ToolUseContext & LocalJSXCommandContext,
|
||||
): Promise<React.ReactNode> {
|
||||
// Dynamic require to avoid pulling proactive into non-gated builds
|
||||
const mod =
|
||||
require('../proactive/index.js') as typeof import('../proactive/index.js')
|
||||
|
||||
if (mod.isProactiveActive()) {
|
||||
mod.deactivateProactive()
|
||||
onDone('Proactive mode disabled', { display: 'system' })
|
||||
} else {
|
||||
mod.activateProactive('slash_command')
|
||||
onDone(
|
||||
'Proactive mode enabled — model will work autonomously between ticks',
|
||||
{
|
||||
display: 'system',
|
||||
metaMessages: [
|
||||
'<system-reminder>\nProactive mode is now enabled. You will receive periodic <tick> prompts. Do useful work on each tick, or call Sleep if there is nothing to do. Do not output "still waiting" — either act or sleep.\n</system-reminder>',
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
}),
|
||||
} satisfies Command
|
||||
|
||||
export default proactive
|
||||
11
src/commands/send/index.ts
Normal file
11
src/commands/send/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const send = {
|
||||
type: 'local',
|
||||
name: 'send',
|
||||
description: 'Send a message to a connected sub CLI',
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./send.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default send
|
||||
97
src/commands/send/send.ts
Normal file
97
src/commands/send/send.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { getSlaveClient } from '../../hooks/useMasterMonitor.js'
|
||||
import { getPipeIpc } from '../../utils/pipeTransport.js'
|
||||
|
||||
export const call: LocalCommandCall = async (args, context) => {
|
||||
const currentState = context.getAppState()
|
||||
|
||||
if (getPipeIpc(currentState).role !== 'master') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Not in master mode. Use /attach <pipe-name> first.',
|
||||
}
|
||||
}
|
||||
|
||||
// Parse: first word is pipe name, rest is the message
|
||||
const trimmed = args.trim()
|
||||
const spaceIdx = trimmed.indexOf(' ')
|
||||
if (spaceIdx === -1) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Usage: /send <pipe-name> <message>',
|
||||
}
|
||||
}
|
||||
|
||||
const targetName = trimmed.slice(0, spaceIdx)
|
||||
const message = trimmed.slice(spaceIdx + 1).trim()
|
||||
|
||||
if (!message) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Usage: /send <pipe-name> <message>',
|
||||
}
|
||||
}
|
||||
|
||||
const client = getSlaveClient(targetName)
|
||||
if (!client) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Not attached to "${targetName}". Use /status to see connected sub sessions.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!client.connected) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Connection to "${targetName}" is closed. Use /detach ${targetName} and re-attach.`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
client.send({
|
||||
type: 'prompt',
|
||||
data: message,
|
||||
})
|
||||
|
||||
// Record the sent prompt in history
|
||||
context.setAppState(prev => {
|
||||
const slave = getPipeIpc(prev).slaves[targetName]
|
||||
if (!slave) return prev
|
||||
return {
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...getPipeIpc(prev),
|
||||
slaves: {
|
||||
...getPipeIpc(prev).slaves,
|
||||
[targetName]: {
|
||||
...slave,
|
||||
status: 'busy' as const,
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
lastSummary: `Queued: ${message}`,
|
||||
lastEventType: 'prompt',
|
||||
history: [
|
||||
...slave.history,
|
||||
{
|
||||
type: 'prompt' as const,
|
||||
content: message,
|
||||
from: getPipeIpc(currentState).serverName ?? 'master',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Sent to "${targetName}": ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Failed to send to "${targetName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
174
src/commands/subscribe-pr.ts
Normal file
174
src/commands/subscribe-pr.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import type { Command, LocalCommandCall } from '../types/command.js'
|
||||
import { detectCurrentRepositoryWithHost } from '../utils/detectRepository.js'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
|
||||
/**
|
||||
* File-backed store for PR webhook subscriptions.
|
||||
* Each subscription tracks the repo + PR number so the bridge layer
|
||||
* (useReplBridge / webhookSanitizer) can filter inbound events.
|
||||
*/
|
||||
interface PRSubscription {
|
||||
repo: string // "owner/repo"
|
||||
prNumber: number
|
||||
subscribedAt: string // ISO 8601
|
||||
}
|
||||
|
||||
function getSubscriptionsFilePath(): string {
|
||||
return path.join(getClaudeConfigHomeDir(), 'pr-subscriptions.json')
|
||||
}
|
||||
|
||||
function readSubscriptions(): PRSubscription[] {
|
||||
const filePath = getSubscriptionsFilePath()
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
return JSON.parse(raw) as PRSubscription[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeSubscriptions(subs: PRSubscription[]): void {
|
||||
const filePath = getSubscriptionsFilePath()
|
||||
const dir = path.dirname(filePath)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(filePath, JSON.stringify(subs, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a PR URL or number into { repo, prNumber }.
|
||||
*
|
||||
* Accepts:
|
||||
* - Full URL: https://github.com/owner/repo/pull/123
|
||||
* - Short ref: owner/repo#123
|
||||
* - Bare number: 123 (uses the current git repository)
|
||||
*/
|
||||
async function parsePRArg(
|
||||
arg: string,
|
||||
): Promise<{ repo: string; prNumber: number } | { error: string }> {
|
||||
const trimmed = arg.trim()
|
||||
|
||||
// Full GitHub PR URL
|
||||
const urlMatch = trimmed.match(
|
||||
/^https?:\/\/[^/]+\/([^/]+\/[^/]+)\/pull\/(\d+)/,
|
||||
)
|
||||
if (urlMatch) {
|
||||
return { repo: urlMatch[1]!, prNumber: parseInt(urlMatch[2]!, 10) }
|
||||
}
|
||||
|
||||
// Short ref: owner/repo#123
|
||||
const shortMatch = trimmed.match(/^([^/]+\/[^/]+)#(\d+)$/)
|
||||
if (shortMatch) {
|
||||
return { repo: shortMatch[1]!, prNumber: parseInt(shortMatch[2]!, 10) }
|
||||
}
|
||||
|
||||
// Bare number — resolve repo from current git checkout
|
||||
const numMatch = trimmed.match(/^#?(\d+)$/)
|
||||
if (numMatch) {
|
||||
const prNumber = parseInt(numMatch[1]!, 10)
|
||||
const detected = await detectCurrentRepositoryWithHost()
|
||||
if (!detected) {
|
||||
return {
|
||||
error:
|
||||
'Could not detect the GitHub repository for the current directory. Provide a full PR URL instead.',
|
||||
}
|
||||
}
|
||||
const repo = `${detected.owner}/${detected.name}`
|
||||
return { repo, prNumber }
|
||||
}
|
||||
|
||||
return {
|
||||
error: `Unrecognised PR reference: "${trimmed}". Expected a PR URL, owner/repo#123, or a PR number.`,
|
||||
}
|
||||
}
|
||||
|
||||
const call: LocalCommandCall = async (args, _context) => {
|
||||
const trimmed = args.trim()
|
||||
|
||||
// List current subscriptions
|
||||
if (!trimmed || trimmed === '--list' || trimmed === 'list') {
|
||||
const subs = readSubscriptions()
|
||||
if (subs.length === 0) {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'No active PR subscriptions. Usage: /subscribe-pr <pr-url-or-number>',
|
||||
}
|
||||
}
|
||||
const lines = subs.map(
|
||||
(s) => ` ${s.repo}#${s.prNumber} (since ${s.subscribedAt})`,
|
||||
)
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Active PR subscriptions:\n${lines.join('\n')}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe
|
||||
if (trimmed.startsWith('--remove ') || trimmed.startsWith('remove ')) {
|
||||
const rest = trimmed.replace(/^(--remove|remove)\s+/, '')
|
||||
const parsed = await parsePRArg(rest)
|
||||
if ('error' in parsed) {
|
||||
return { type: 'text', value: parsed.error }
|
||||
}
|
||||
const subs = readSubscriptions()
|
||||
const before = subs.length
|
||||
const after = subs.filter(
|
||||
(s) => !(s.repo === parsed.repo && s.prNumber === parsed.prNumber),
|
||||
)
|
||||
if (after.length === before) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `No subscription found for ${parsed.repo}#${parsed.prNumber}.`,
|
||||
}
|
||||
}
|
||||
writeSubscriptions(after)
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Unsubscribed from ${parsed.repo}#${parsed.prNumber}.`,
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe
|
||||
const parsed = await parsePRArg(trimmed)
|
||||
if ('error' in parsed) {
|
||||
return { type: 'text', value: parsed.error }
|
||||
}
|
||||
|
||||
const subs = readSubscriptions()
|
||||
const existing = subs.find(
|
||||
(s) => s.repo === parsed.repo && s.prNumber === parsed.prNumber,
|
||||
)
|
||||
if (existing) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Already subscribed to ${parsed.repo}#${parsed.prNumber} (since ${existing.subscribedAt}).`,
|
||||
}
|
||||
}
|
||||
|
||||
subs.push({
|
||||
repo: parsed.repo,
|
||||
prNumber: parsed.prNumber,
|
||||
subscribedAt: new Date().toISOString(),
|
||||
})
|
||||
writeSubscriptions(subs)
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Subscribed to ${parsed.repo}#${parsed.prNumber}. You will receive notifications for comments, CI status, and reviews.`,
|
||||
}
|
||||
}
|
||||
|
||||
const subscribePr = {
|
||||
type: 'local',
|
||||
name: 'subscribe-pr',
|
||||
aliases: ['watch-pr'],
|
||||
description: 'Subscribe to GitHub PR activity (comments, CI, reviews)',
|
||||
argumentHint: '<pr-url-or-number>',
|
||||
supportsNonInteractive: false,
|
||||
isHidden: true,
|
||||
load: () => Promise.resolve({ call }),
|
||||
} satisfies Command
|
||||
|
||||
export default subscribePr
|
||||
1
src/commands/torch.ts
Normal file
1
src/commands/torch.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default null
|
||||
@@ -1,3 +1,25 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
const _default: Record<string, unknown> = {};
|
||||
export default _default;
|
||||
import type { Command, LocalCommandCall } from '../../types/command.js'
|
||||
import { getWorkflowCommands } from '../../tools/WorkflowTool/createWorkflowCommand.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
|
||||
const call: LocalCommandCall = async (_args, _context) => {
|
||||
const commands = await getWorkflowCommands(getCwd())
|
||||
if (commands.length === 0) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'No workflows found. Add workflow files to .claude/workflows/ (YAML or Markdown).',
|
||||
}
|
||||
}
|
||||
const list = commands.map((cmd) => ` /${cmd.name} - ${cmd.description}`).join('\n')
|
||||
return { type: 'text', value: `Available workflows:\n${list}` }
|
||||
}
|
||||
|
||||
const workflows = {
|
||||
type: 'local',
|
||||
name: 'workflows',
|
||||
description: 'List available workflow scripts',
|
||||
supportsNonInteractive: true,
|
||||
load: () => Promise.resolve({ call }),
|
||||
} satisfies Command
|
||||
|
||||
export default workflows
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import figures from 'figures'
|
||||
import figures from 'figures';
|
||||
import React, {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -29,53 +29,53 @@ import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggest
|
||||
import type { StickyPrompt } from './VirtualMessageList.js'
|
||||
|
||||
/** Rows of transcript context kept visible above the modal pane's ▔ divider. */
|
||||
const MODAL_TRANSCRIPT_PEEK = 2
|
||||
const MODAL_TRANSCRIPT_PEEK = 2;
|
||||
|
||||
/** Context for scroll-derived chrome (sticky header, pill). StickyTracker
|
||||
* in VirtualMessageList writes via this instead of threading a callback
|
||||
* up through Messages → REPL → FullscreenLayout. The setter is stable so
|
||||
* consuming this context never causes re-renders. */
|
||||
export const ScrollChromeContext = createContext<{
|
||||
setStickyPrompt: (p: StickyPrompt | null) => void
|
||||
}>({ setStickyPrompt: () => {} })
|
||||
setStickyPrompt: (p: StickyPrompt | null) => void;
|
||||
}>({ setStickyPrompt: () => {} });
|
||||
|
||||
type Props = {
|
||||
/** Content that scrolls (messages, tool output) */
|
||||
scrollable: ReactNode
|
||||
scrollable: ReactNode;
|
||||
/** Content pinned to the bottom (spinner, prompt, permissions) */
|
||||
bottom: ReactNode
|
||||
bottom: ReactNode;
|
||||
/** Content rendered inside the ScrollBox after messages — user can scroll
|
||||
* up to see context while it's showing (used by PermissionRequest). */
|
||||
overlay?: ReactNode
|
||||
overlay?: ReactNode;
|
||||
/** Absolute-positioned content anchored at the bottom-right of the
|
||||
* ScrollBox area, floating over scrollback. Rendered inside the flexGrow
|
||||
* region (not the bottom slot) so the overflowY:hidden cap doesn't clip
|
||||
* it. Fullscreen only — used for the companion speech bubble. */
|
||||
bottomFloat?: ReactNode
|
||||
bottomFloat?: ReactNode;
|
||||
/** Slash-command dialog content. Rendered in an absolute-positioned
|
||||
* bottom-anchored pane (▔ divider, paddingX=2) that paints over the
|
||||
* ScrollBox AND bottom slot. Provides ModalContext so Pane/Dialog inside
|
||||
* skip their own frame. Fullscreen only; inline after overlay otherwise. */
|
||||
modal?: ReactNode
|
||||
modal?: ReactNode;
|
||||
/** Ref passed via ModalContext so Tabs (or any scroll-owning descendant)
|
||||
* can attach it to their own ScrollBox for tall content. */
|
||||
modalScrollRef?: React.RefObject<ScrollBoxHandle | null>
|
||||
modalScrollRef?: React.RefObject<ScrollBoxHandle | null>;
|
||||
/** Ref to the scroll box for keyboard scrolling. RefObject (not Ref) so
|
||||
* pillVisible's useSyncExternalStore can subscribe to scroll changes. */
|
||||
scrollRef?: RefObject<ScrollBoxHandle | null>
|
||||
scrollRef?: RefObject<ScrollBoxHandle | null>;
|
||||
/** Y-position (scrollHeight at snapshot) of the unseen-divider. Pill
|
||||
* shows while viewport bottom hasn't reached this. Ref so REPL doesn't
|
||||
* re-render on the one-shot snapshot write. */
|
||||
dividerYRef?: RefObject<number | null>
|
||||
dividerYRef?: RefObject<number | null>;
|
||||
/** Force-hide the pill (e.g. viewing a sub-agent task). */
|
||||
hidePill?: boolean
|
||||
hidePill?: boolean;
|
||||
/** Force-hide the sticky prompt header (e.g. viewing a teammate task). */
|
||||
hideSticky?: boolean
|
||||
hideSticky?: boolean;
|
||||
/** Count for the pill text. 0 → "Jump to bottom", >0 → "N new messages". */
|
||||
newMessageCount?: number
|
||||
newMessageCount?: number;
|
||||
/** Called when the user clicks the "N new" pill. */
|
||||
onPillClick?: () => void
|
||||
}
|
||||
onPillClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks the in-transcript "N new messages" divider position while the
|
||||
@@ -98,33 +98,33 @@ export function useUnseenDivider(messageCount: number): {
|
||||
/** Index into messages[] where the divider line renders. Cleared on
|
||||
* sticky-resume (scroll back to bottom) so the "N new" line doesn't
|
||||
* linger once everything is visible. */
|
||||
dividerIndex: number | null
|
||||
dividerIndex: number | null;
|
||||
/** scrollHeight snapshot at first scroll-away — the divider's y-position.
|
||||
* FullscreenLayout subscribes to ScrollBox and compares viewport bottom
|
||||
* against this for pillVisible. Ref so writes don't re-render REPL. */
|
||||
dividerYRef: RefObject<number | null>
|
||||
onScrollAway: (handle: ScrollBoxHandle) => void
|
||||
onRepin: () => void
|
||||
dividerYRef: RefObject<number | null>;
|
||||
onScrollAway: (handle: ScrollBoxHandle) => void;
|
||||
onRepin: () => void;
|
||||
/** Scroll the handle so the divider line is at the top of the viewport. */
|
||||
jumpToNew: (handle: ScrollBoxHandle | null) => void
|
||||
jumpToNew: (handle: ScrollBoxHandle | null) => void;
|
||||
/** Shift dividerIndex and dividerYRef when messages are prepended
|
||||
* (infinite scroll-back). indexDelta = number of messages prepended;
|
||||
* heightDelta = content height growth in rows. */
|
||||
shiftDivider: (indexDelta: number, heightDelta: number) => void
|
||||
shiftDivider: (indexDelta: number, heightDelta: number) => void;
|
||||
} {
|
||||
const [dividerIndex, setDividerIndex] = useState<number | null>(null)
|
||||
const [dividerIndex, setDividerIndex] = useState<number | null>(null);
|
||||
// Ref holds the current count for onScrollAway to snapshot. Written in
|
||||
// the render body (not useEffect) so wheel events arriving between a
|
||||
// message-append render and its effect flush don't capture a stale
|
||||
// count (off-by-one in the baseline). React Compiler bails out here —
|
||||
// acceptable for a hook instantiated once in REPL.
|
||||
const countRef = useRef(messageCount)
|
||||
countRef.current = messageCount
|
||||
const countRef = useRef(messageCount);
|
||||
countRef.current = messageCount;
|
||||
// scrollHeight snapshot — the divider's y in content coords. Ref-only:
|
||||
// read synchronously in onScrollAway (setState is batched, can't
|
||||
// read-then-write in the same callback) AND by FullscreenLayout's
|
||||
// pillVisible subscription. null = pinned to bottom.
|
||||
const dividerYRef = useRef<number | null>(null)
|
||||
const dividerYRef = useRef<number | null>(null);
|
||||
|
||||
const onRepin = useCallback(() => {
|
||||
// Don't clear dividerYRef here — a trackpad momentum wheel event
|
||||
@@ -132,8 +132,8 @@ export function useUnseenDivider(messageCount: number): {
|
||||
// overriding the setDividerIndex(null) below. The useEffect below
|
||||
// clears the ref after React commits the null dividerIndex, so the
|
||||
// ref stays non-null until the state settles.
|
||||
setDividerIndex(null)
|
||||
}, [])
|
||||
setDividerIndex(null);
|
||||
}, []);
|
||||
|
||||
const onScrollAway = useCallback((handle: ScrollBoxHandle) => {
|
||||
// Nothing below the viewport → nothing to jump to. Covers both:
|
||||
@@ -145,24 +145,21 @@ export function useUnseenDivider(messageCount: number): {
|
||||
// at max (Sarah Deaton, #claude-code-feedback 2026-03-15)
|
||||
// pendingDelta: scrollBy accumulates without updating scrollTop. Without
|
||||
// it, wheeling up from max would see scrollTop==max and suppress the pill.
|
||||
const max = Math.max(
|
||||
0,
|
||||
handle.getScrollHeight() - handle.getViewportHeight(),
|
||||
)
|
||||
if (handle.getScrollTop() + handle.getPendingDelta() >= max) return
|
||||
const max = Math.max(0, handle.getScrollHeight() - handle.getViewportHeight());
|
||||
if (handle.getScrollTop() + handle.getPendingDelta() >= max) return;
|
||||
// Snapshot only on the FIRST scroll-away. onScrollAway fires on EVERY
|
||||
// scroll action (not just the initial break from sticky) — this guard
|
||||
// preserves the original baseline so the count doesn't reset on the
|
||||
// second PageUp. Subsequent calls are ref-only no-ops (no REPL re-render).
|
||||
if (dividerYRef.current === null) {
|
||||
dividerYRef.current = handle.getScrollHeight()
|
||||
dividerYRef.current = handle.getScrollHeight();
|
||||
// New scroll-away session → move the divider here (replaces old one)
|
||||
setDividerIndex(countRef.current)
|
||||
setDividerIndex(countRef.current);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const jumpToNew = useCallback((handle: ScrollBoxHandle | null) => {
|
||||
if (!handle) return
|
||||
if (!handle) return;
|
||||
// scrollToBottom (not scrollTo(dividerY)): sets stickyScroll=true so
|
||||
// useVirtualScroll mounts the tail and render-node-to-output pins
|
||||
// scrollTop=maxScroll. scrollTo sets stickyScroll=false → the clamp
|
||||
@@ -170,8 +167,8 @@ export function useUnseenDivider(messageCount: number): {
|
||||
// back, stopping short. The divider stays rendered (dividerIndex
|
||||
// unchanged) so users see where new messages started; the clear on
|
||||
// next submit/explicit scroll-to-bottom handles cleanup.
|
||||
handle.scrollToBottom()
|
||||
}, [])
|
||||
handle.scrollToBottom();
|
||||
}, []);
|
||||
|
||||
// Sync dividerYRef with dividerIndex. When onRepin fires (submit,
|
||||
// scroll-to-bottom), it sets dividerIndex=null but leaves the ref
|
||||
@@ -184,22 +181,19 @@ export function useUnseenDivider(messageCount: number): {
|
||||
// below the divider index, the divider would point at nothing.
|
||||
useEffect(() => {
|
||||
if (dividerIndex === null) {
|
||||
dividerYRef.current = null
|
||||
dividerYRef.current = null;
|
||||
} else if (messageCount < dividerIndex) {
|
||||
dividerYRef.current = null
|
||||
setDividerIndex(null)
|
||||
dividerYRef.current = null;
|
||||
setDividerIndex(null);
|
||||
}
|
||||
}, [messageCount, dividerIndex])
|
||||
}, [messageCount, dividerIndex]);
|
||||
|
||||
const shiftDivider = useCallback(
|
||||
(indexDelta: number, heightDelta: number) => {
|
||||
setDividerIndex(idx => (idx === null ? null : idx + indexDelta))
|
||||
if (dividerYRef.current !== null) {
|
||||
dividerYRef.current += heightDelta
|
||||
}
|
||||
},
|
||||
[],
|
||||
)
|
||||
const shiftDivider = useCallback((indexDelta: number, heightDelta: number) => {
|
||||
setDividerIndex(idx => (idx === null ? null : idx + indexDelta));
|
||||
if (dividerYRef.current !== null) {
|
||||
dividerYRef.current += heightDelta;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
dividerIndex,
|
||||
@@ -208,7 +202,7 @@ export function useUnseenDivider(messageCount: number): {
|
||||
onRepin,
|
||||
jumpToNew,
|
||||
shiftDivider,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,25 +213,22 @@ export function useUnseenDivider(messageCount: number): {
|
||||
* carry text — tool-use-only entries are skipped (like progress messages)
|
||||
* so "⏺ Searched for 13 patterns, read 6 files" doesn't tick the pill.
|
||||
*/
|
||||
export function countUnseenAssistantTurns(
|
||||
messages: readonly Message[],
|
||||
dividerIndex: number,
|
||||
): number {
|
||||
let count = 0
|
||||
let prevWasAssistant = false
|
||||
export function countUnseenAssistantTurns(messages: readonly Message[], dividerIndex: number): number {
|
||||
let count = 0;
|
||||
let prevWasAssistant = false;
|
||||
for (let i = dividerIndex; i < messages.length; i++) {
|
||||
const m = messages[i]!
|
||||
if (m.type === 'progress') continue
|
||||
const m = messages[i]!;
|
||||
if (m.type === 'progress') continue;
|
||||
// Tool-use-only assistant entries aren't "new messages" to the user —
|
||||
// skip them the same way we skip progress. prevWasAssistant is NOT
|
||||
// updated, so a text block immediately following still counts as the
|
||||
// same turn (tool_use + text from one API response = 1).
|
||||
if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue
|
||||
const isAssistant = m.type === 'assistant'
|
||||
if (isAssistant && !prevWasAssistant) count++
|
||||
prevWasAssistant = isAssistant
|
||||
if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue;
|
||||
const isAssistant = m.type === 'assistant';
|
||||
if (isAssistant && !prevWasAssistant) count++;
|
||||
prevWasAssistant = isAssistant;
|
||||
}
|
||||
return count
|
||||
return count;
|
||||
}
|
||||
|
||||
function assistantHasVisibleText(m: Message): boolean {
|
||||
@@ -246,10 +237,10 @@ function assistantHasVisibleText(m: Message): boolean {
|
||||
for (const b of m.message!.content) {
|
||||
if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
export type UnseenDivider = { firstUnseenUuid: Message['uuid']; count: number }
|
||||
export type UnseenDivider = { firstUnseenUuid: Message['uuid']; count: number };
|
||||
|
||||
/**
|
||||
* Builds the unseenDivider object REPL passes to Messages + the pill.
|
||||
@@ -265,23 +256,22 @@ export function computeUnseenDivider(
|
||||
messages: readonly Message[],
|
||||
dividerIndex: number | null,
|
||||
): UnseenDivider | undefined {
|
||||
if (dividerIndex === null) return undefined
|
||||
if (dividerIndex === null) return undefined;
|
||||
// Skip progress and null-rendering attachments when picking the divider
|
||||
// anchor — Messages.tsx filters these out of renderableMessages before the
|
||||
// dividerBeforeIndex search, so their UUID wouldn't be found (CC-724).
|
||||
// Hook attachments use randomUUID() so nothing shares their 24-char prefix.
|
||||
let anchorIdx = dividerIndex
|
||||
let anchorIdx = dividerIndex;
|
||||
while (
|
||||
anchorIdx < messages.length &&
|
||||
(messages[anchorIdx]?.type === 'progress' ||
|
||||
isNullRenderingAttachment(messages[anchorIdx]!))
|
||||
(messages[anchorIdx]?.type === 'progress' || isNullRenderingAttachment(messages[anchorIdx]!))
|
||||
) {
|
||||
anchorIdx++
|
||||
anchorIdx++;
|
||||
}
|
||||
const uuid = messages[anchorIdx]?.uuid
|
||||
if (!uuid) return undefined
|
||||
const count = countUnseenAssistantTurns(messages, dividerIndex)
|
||||
return { firstUnseenUuid: uuid, count: Math.max(1, count) }
|
||||
const uuid = messages[anchorIdx]?.uuid;
|
||||
if (!uuid) return undefined;
|
||||
const count = countUnseenAssistantTurns(messages, dividerIndex);
|
||||
return { firstUnseenUuid: uuid, count: Math.max(1, count) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,56 +300,53 @@ export function FullscreenLayout({
|
||||
newMessageCount = 0,
|
||||
onPillClick,
|
||||
}: Props): React.ReactNode {
|
||||
const { rows: terminalRows, columns } = useTerminalSize()
|
||||
const { rows: terminalRows, columns } = useTerminalSize();
|
||||
// Scroll-derived chrome state lives HERE, not in REPL. StickyTracker
|
||||
// writes via ScrollChromeContext; pillVisible subscribes directly to
|
||||
// ScrollBox. Both change rarely (pill flips once per threshold crossing,
|
||||
// sticky changes ~5-20×/transcript) — re-rendering FullscreenLayout on
|
||||
// those is fine; re-rendering the 6966-line REPL + its 22+ useAppState
|
||||
// selectors per-scroll-frame was not.
|
||||
const [stickyPrompt, setStickyPrompt] = useState<StickyPrompt | null>(null)
|
||||
const chromeCtx = useMemo(() => ({ setStickyPrompt }), [])
|
||||
const [stickyPrompt, setStickyPrompt] = useState<StickyPrompt | null>(null);
|
||||
const chromeCtx = useMemo(() => ({ setStickyPrompt }), []);
|
||||
// Boolean-quantized scroll subscription. Snapshot is "is viewport bottom
|
||||
// above the divider y?" — Object.is on a boolean → FullscreenLayout only
|
||||
// re-renders when the pill should actually flip, not per-frame.
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) =>
|
||||
scrollRef?.current?.subscribe(listener) ?? (() => {}),
|
||||
(listener: () => void) => scrollRef?.current?.subscribe(listener) ?? (() => {}),
|
||||
[scrollRef],
|
||||
)
|
||||
);
|
||||
const pillVisible = useSyncExternalStore(subscribe, () => {
|
||||
const s = scrollRef?.current
|
||||
const dividerY = dividerYRef?.current
|
||||
if (!s || dividerY == null) return false
|
||||
return (
|
||||
s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY
|
||||
)
|
||||
})
|
||||
const s = scrollRef?.current;
|
||||
const dividerY = dividerYRef?.current;
|
||||
if (!s || dividerY == null) return false;
|
||||
return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY;
|
||||
});
|
||||
// Wire up hyperlink click handling — in fullscreen mode, mouse tracking
|
||||
// intercepts clicks before the terminal can open OSC 8 links natively.
|
||||
useLayoutEffect(() => {
|
||||
if (!isFullscreenEnvEnabled()) return
|
||||
const ink = instances.get(process.stdout)
|
||||
if (!ink) return
|
||||
if (!isFullscreenEnvEnabled()) return;
|
||||
const ink = instances.get(process.stdout);
|
||||
if (!ink) return;
|
||||
ink.onHyperlinkClick = url => {
|
||||
// Most OSC 8 links emitted by Claude Code are file:// URLs from
|
||||
// FilePathLink (FileEdit/FileWrite/FileRead tool output). openBrowser
|
||||
// rejects non-http(s) protocols — route file: to openPath instead.
|
||||
if (url.startsWith('file:')) {
|
||||
try {
|
||||
void openPath(fileURLToPath(url))
|
||||
void openPath(fileURLToPath(url));
|
||||
} catch {
|
||||
// Malformed file: URLs (e.g. file://host/path from plain-text
|
||||
// detection) cause fileURLToPath to throw — ignore silently.
|
||||
}
|
||||
} else {
|
||||
void openBrowser(url)
|
||||
void openBrowser(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
ink.onHyperlinkClick = undefined
|
||||
}
|
||||
}, [])
|
||||
ink.onHyperlinkClick = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isFullscreenEnvEnabled()) {
|
||||
// Overlay renders BELOW messages inside the same ScrollBox — user can
|
||||
@@ -379,50 +366,41 @@ export function FullscreenLayout({
|
||||
// row 0. On next scroll the onChange fires with a fresh {text} and
|
||||
// header comes back (viewportTop 0→1, a single 1-row shift —
|
||||
// acceptable since user explicitly scrolled).
|
||||
const sticky = hideSticky ? null : stickyPrompt
|
||||
const headerPrompt =
|
||||
sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null
|
||||
const padCollapsed = sticky != null && overlay == null
|
||||
const sticky = hideSticky ? null : stickyPrompt;
|
||||
const headerPrompt = sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null;
|
||||
const padCollapsed = sticky != null && overlay == null;
|
||||
return (
|
||||
<PromptOverlayProvider>
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
{headerPrompt && (
|
||||
<StickyPromptHeader
|
||||
text={headerPrompt.text}
|
||||
onClick={headerPrompt.scrollTo}
|
||||
/>
|
||||
)}
|
||||
<ScrollBox
|
||||
ref={scrollRef}
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
paddingTop={padCollapsed ? 0 : 1}
|
||||
stickyScroll
|
||||
>
|
||||
<ScrollChromeContext value={chromeCtx}>
|
||||
{scrollable}
|
||||
</ScrollChromeContext>
|
||||
{overlay}
|
||||
</ScrollBox>
|
||||
{!hidePill && pillVisible && overlay == null && (
|
||||
<NewMessagesPill count={newMessageCount} onClick={onPillClick} />
|
||||
)}
|
||||
{bottomFloat != null && (
|
||||
<Box position="absolute" bottom={0} right={0} opaque>
|
||||
{bottomFloat}
|
||||
<Box flexDirection="row" flexGrow={1} overflow="hidden" width="100%">
|
||||
<Box flexDirection="column" flexGrow={1} width={columns} overflow="hidden">
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
{headerPrompt && <StickyPromptHeader text={headerPrompt.text} onClick={headerPrompt.scrollTo} />}
|
||||
<ScrollBox
|
||||
ref={scrollRef}
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
paddingTop={padCollapsed ? 0 : 1}
|
||||
stickyScroll
|
||||
>
|
||||
<ScrollChromeContext value={chromeCtx}>{scrollable}</ScrollChromeContext>
|
||||
{overlay}
|
||||
</ScrollBox>
|
||||
{!hidePill && pillVisible && overlay == null && (
|
||||
<NewMessagesPill count={newMessageCount} onClick={onPillClick} />
|
||||
)}
|
||||
{bottomFloat != null && (
|
||||
<Box position="absolute" bottom={0} right={0} opaque>
|
||||
{bottomFloat}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column" flexShrink={0} width="100%" maxHeight="50%">
|
||||
<SuggestionsOverlay />
|
||||
<DialogOverlay />
|
||||
<Box flexDirection="column" width="100%" flexGrow={1} overflowY="hidden">
|
||||
{bottom}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column" flexShrink={0} width="100%" maxHeight="50%">
|
||||
<SuggestionsOverlay />
|
||||
<DialogOverlay />
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
overflowY="hidden"
|
||||
>
|
||||
{bottom}
|
||||
</Box>
|
||||
</Box>
|
||||
{modal != null && (
|
||||
@@ -465,19 +443,14 @@ export function FullscreenLayout({
|
||||
<Box flexShrink={0}>
|
||||
<Text color="permission">{'▔'.repeat(columns)}</Text>
|
||||
</Box>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={2}
|
||||
flexShrink={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box flexDirection="column" paddingX={2} flexShrink={0} overflow="hidden">
|
||||
{modal}
|
||||
</Box>
|
||||
</Box>
|
||||
</ModalContext>
|
||||
)}
|
||||
</PromptOverlayProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -487,7 +460,7 @@ export function FullscreenLayout({
|
||||
{overlay}
|
||||
{modal}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Slack-style pill. Absolute overlay at bottom={0} of the scrollwrap — floats
|
||||
@@ -497,42 +470,18 @@ export function FullscreenLayout({
|
||||
// (absoluteRectsPrev third-pass in render-node-to-output.ts, #23939). Shows
|
||||
// "Jump to bottom" when count is 0 (scrolled away but no new messages yet —
|
||||
// the dead zone where users previously thought chat stalled).
|
||||
function NewMessagesPill({
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
count: number
|
||||
onClick?: () => void
|
||||
}): React.ReactNode {
|
||||
const [hover, setHover] = useState(false)
|
||||
function NewMessagesPill({ count, onClick }: { count: number; onClick?: () => void }): React.ReactNode {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Box
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
<Text
|
||||
backgroundColor={
|
||||
hover ? 'userMessageBackgroundHover' : 'userMessageBackground'
|
||||
}
|
||||
dimColor
|
||||
>
|
||||
<Box position="absolute" bottom={0} left={0} right={0} justifyContent="center">
|
||||
<Box onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
||||
<Text backgroundColor={hover ? 'userMessageBackgroundHover' : 'userMessageBackground'} dimColor>
|
||||
{' '}
|
||||
{count > 0
|
||||
? `${count} new ${plural(count, 'message')}`
|
||||
: 'Jump to bottom'}{' '}
|
||||
{figures.arrowDown}{' '}
|
||||
{count > 0 ? `${count} new ${plural(count, 'message')}` : 'Jump to bottom'} {figures.arrowDown}{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Context breadcrumb: when scrolled up into history, pin the current
|
||||
@@ -547,23 +496,15 @@ function NewMessagesPill({
|
||||
// even with scrollTop unchanged (the DECSTBM region top shifts with the
|
||||
// ScrollBox, and the diff engine sees "everything moved"). Fixed height
|
||||
// keeps the ScrollBox anchored; only the header TEXT changes, not its box.
|
||||
function StickyPromptHeader({
|
||||
text,
|
||||
onClick,
|
||||
}: {
|
||||
text: string
|
||||
onClick: () => void
|
||||
}): React.ReactNode {
|
||||
const [hover, setHover] = useState(false)
|
||||
function StickyPromptHeader({ text, onClick }: { text: string; onClick: () => void }): React.ReactNode {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
height={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
hover ? 'userMessageBackgroundHover' : 'userMessageBackground'
|
||||
}
|
||||
backgroundColor={hover ? 'userMessageBackgroundHover' : 'userMessageBackground'}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
@@ -572,7 +513,7 @@ function StickyPromptHeader({
|
||||
{figures.pointer} {text}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Slash-command suggestion overlay — see promptOverlayContext.tsx for why
|
||||
@@ -584,19 +525,10 @@ function StickyPromptHeader({
|
||||
// flex-end here: they would create empty padding rows that shift visible
|
||||
// items down into the prompt area when the list has fewer items than max.
|
||||
function SuggestionsOverlay(): React.ReactNode {
|
||||
const data = usePromptOverlay()
|
||||
if (!data || data.suggestions.length === 0) return null
|
||||
const data = usePromptOverlay();
|
||||
if (!data || data.suggestions.length === 0) return null;
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="100%"
|
||||
left={0}
|
||||
right={0}
|
||||
paddingX={2}
|
||||
paddingTop={1}
|
||||
flexDirection="column"
|
||||
opaque
|
||||
>
|
||||
<Box position="absolute" bottom="100%" left={0} right={0} paddingX={2} paddingTop={1} flexDirection="column" opaque>
|
||||
<PromptInputFooterSuggestions
|
||||
suggestions={data.suggestions}
|
||||
selectedSuggestion={data.selectedSuggestion}
|
||||
@@ -604,18 +536,18 @@ function SuggestionsOverlay(): React.ReactNode {
|
||||
overlay
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Dialog portaled from PromptInput (AutoModeOptInDialog) — same clip-escape
|
||||
// pattern as SuggestionsOverlay. Renders later in tree order so it paints
|
||||
// over suggestions if both are ever up (they shouldn't be).
|
||||
function DialogOverlay(): React.ReactNode {
|
||||
const node = usePromptOverlayDialog()
|
||||
if (!node) return null
|
||||
const node = usePromptOverlayDialog();
|
||||
if (!node) return null;
|
||||
return (
|
||||
<Box position="absolute" bottom="100%" left={0} right={0} opaque>
|
||||
{node}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import { memo, type ReactNode, useMemo, useRef } from 'react'
|
||||
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'
|
||||
import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js'
|
||||
import { useSetPromptOverlay } from '../../context/promptOverlayContext.js'
|
||||
@@ -8,14 +8,16 @@ import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'
|
||||
import type { IDESelection } from '../../hooks/useIdeSelection.js'
|
||||
import { useSettings } from '../../hooks/useSettings.js'
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { Box, Text, useInput } from '@anthropic/ink'
|
||||
import type { MCPServerConnection } from '../../services/mcp/types.js'
|
||||
import { useAppState } from '../../state/AppState.js'
|
||||
import { useRegisterOverlay } from '../../context/overlayContext.js'
|
||||
import { useAppState, useSetAppState } from '../../state/AppState.js'
|
||||
import type { ToolPermissionContext } from '../../Tool.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js'
|
||||
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'
|
||||
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
|
||||
import { getPipeDisplayRole, isPipeControlled } from '../../utils/pipeTransport.js'
|
||||
import { isUndercover } from '../../utils/undercover.js'
|
||||
import {
|
||||
CoordinatorTaskPanel,
|
||||
@@ -28,49 +30,48 @@ import {
|
||||
} from '../StatusLine.js'
|
||||
import { Notifications } from './Notifications.js'
|
||||
import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'
|
||||
import {
|
||||
PromptInputFooterSuggestions,
|
||||
type SuggestionItem,
|
||||
} from './PromptInputFooterSuggestions.js'
|
||||
|
||||
// Inline pipe status is shown only after /pipes sets pipeIpc.statusVisible.
|
||||
import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js'
|
||||
import { PromptInputHelpMenu } from './PromptInputHelpMenu.js'
|
||||
|
||||
type Props = {
|
||||
apiKeyStatus: VerificationStatus
|
||||
debug: boolean
|
||||
apiKeyStatus: VerificationStatus;
|
||||
debug: boolean;
|
||||
exitMessage: {
|
||||
show: boolean
|
||||
key?: string
|
||||
}
|
||||
vimMode: VimMode | undefined
|
||||
mode: PromptInputMode
|
||||
autoUpdaterResult: AutoUpdaterResult | null
|
||||
isAutoUpdating: boolean
|
||||
verbose: boolean
|
||||
onAutoUpdaterResult: (result: AutoUpdaterResult) => void
|
||||
onChangeIsUpdating: (isUpdating: boolean) => void
|
||||
suggestions: SuggestionItem[]
|
||||
selectedSuggestion: number
|
||||
maxColumnWidth?: number
|
||||
toolPermissionContext: ToolPermissionContext
|
||||
helpOpen: boolean
|
||||
suppressHint: boolean
|
||||
isLoading: boolean
|
||||
tasksSelected: boolean
|
||||
teamsSelected: boolean
|
||||
bridgeSelected: boolean
|
||||
tmuxSelected: boolean
|
||||
teammateFooterIndex?: number
|
||||
ideSelection: IDESelection | undefined
|
||||
mcpClients?: MCPServerConnection[]
|
||||
isPasting?: boolean
|
||||
isInputWrapped?: boolean
|
||||
messages: Message[]
|
||||
isSearching: boolean
|
||||
historyQuery: string
|
||||
setHistoryQuery: (query: string) => void
|
||||
historyFailedMatch: boolean
|
||||
onOpenTasksDialog?: (taskId?: string) => void
|
||||
}
|
||||
show: boolean;
|
||||
key?: string;
|
||||
};
|
||||
vimMode: VimMode | undefined;
|
||||
mode: PromptInputMode;
|
||||
autoUpdaterResult: AutoUpdaterResult | null;
|
||||
isAutoUpdating: boolean;
|
||||
verbose: boolean;
|
||||
onAutoUpdaterResult: (result: AutoUpdaterResult) => void;
|
||||
onChangeIsUpdating: (isUpdating: boolean) => void;
|
||||
suggestions: SuggestionItem[];
|
||||
selectedSuggestion: number;
|
||||
maxColumnWidth?: number;
|
||||
toolPermissionContext: ToolPermissionContext;
|
||||
helpOpen: boolean;
|
||||
suppressHint: boolean;
|
||||
isLoading: boolean;
|
||||
tasksSelected: boolean;
|
||||
teamsSelected: boolean;
|
||||
bridgeSelected: boolean;
|
||||
tmuxSelected: boolean;
|
||||
teammateFooterIndex?: number;
|
||||
ideSelection: IDESelection | undefined;
|
||||
mcpClients?: MCPServerConnection[];
|
||||
isPasting?: boolean;
|
||||
isInputWrapped?: boolean;
|
||||
messages: Message[];
|
||||
isSearching: boolean;
|
||||
historyQuery: string;
|
||||
setHistoryQuery: (query: string) => void;
|
||||
historyFailedMatch: boolean;
|
||||
onOpenTasksDialog?: (taskId?: string) => void;
|
||||
};
|
||||
|
||||
function PromptInputFooter({
|
||||
apiKeyStatus,
|
||||
@@ -106,43 +107,35 @@ function PromptInputFooter({
|
||||
historyFailedMatch,
|
||||
onOpenTasksDialog,
|
||||
}: Props): ReactNode {
|
||||
const settings = useSettings()
|
||||
const { columns, rows } = useTerminalSize()
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
const lastAssistantMessageId = useMemo(
|
||||
() => getLastAssistantMessageId(messages),
|
||||
[messages],
|
||||
)
|
||||
const isNarrow = columns < 80
|
||||
const settings = useSettings();
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
const lastAssistantMessageId = useMemo(() => getLastAssistantMessageId(messages), [messages]);
|
||||
const isNarrow = columns < 80;
|
||||
// In fullscreen the bottom slot is flexShrink:0, so every row here is a row
|
||||
// stolen from the ScrollBox. Drop the optional StatusLine first. Non-fullscreen
|
||||
// has terminal scrollback to absorb overflow, so we never hide StatusLine there.
|
||||
const isFullscreen = isFullscreenEnvEnabled()
|
||||
const isShort = isFullscreen && rows < 24
|
||||
const isFullscreen = isFullscreenEnvEnabled();
|
||||
const isShort = isFullscreen && rows < 24;
|
||||
|
||||
// Pill highlights when tasks is the active footer item AND no specific
|
||||
// agent row is selected. When coordinatorTaskIndex >= 0 the pointer has
|
||||
// moved into CoordinatorTaskPanel, so the pill should un-highlight.
|
||||
// coordinatorTaskCount === 0 covers the bash-only case (no agent rows
|
||||
// exist, pill is the only selectable item).
|
||||
const coordinatorTaskCount = useCoordinatorTaskCount()
|
||||
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex)
|
||||
const pillSelected =
|
||||
tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0)
|
||||
const coordinatorTaskCount = useCoordinatorTaskCount();
|
||||
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex);
|
||||
const pillSelected = tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0);
|
||||
|
||||
// Hide `? for shortcuts` if the user has a custom status line, or during ctrl-r
|
||||
const suppressHint =
|
||||
suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching
|
||||
const suppressHint = suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching;
|
||||
// Fullscreen: portal data to FullscreenLayout — see promptOverlayContext.tsx
|
||||
const overlayData = useMemo(
|
||||
() =>
|
||||
isFullscreen && suggestions.length
|
||||
? { suggestions, selectedSuggestion, maxColumnWidth }
|
||||
: null,
|
||||
() => (isFullscreen && suggestions.length ? { suggestions, selectedSuggestion, maxColumnWidth } : null),
|
||||
[isFullscreen, suggestions, selectedSuggestion, maxColumnWidth],
|
||||
)
|
||||
useSetPromptOverlay(overlayData)
|
||||
);
|
||||
useSetPromptOverlay(overlayData);
|
||||
|
||||
if (suggestions.length && !isFullscreen) {
|
||||
return (
|
||||
@@ -153,13 +146,11 @@ function PromptInputFooter({
|
||||
maxColumnWidth={maxColumnWidth}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (helpOpen) {
|
||||
return (
|
||||
<PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />
|
||||
)
|
||||
return <PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -171,17 +162,10 @@ function PromptInputFooter({
|
||||
gap={isNarrow ? 0 : 1}
|
||||
>
|
||||
<Box flexDirection="column" flexShrink={isNarrow ? 0 : 1}>
|
||||
{mode === 'prompt' &&
|
||||
!isShort &&
|
||||
!exitMessage.show &&
|
||||
!isPasting &&
|
||||
statusLineShouldDisplay(settings) && (
|
||||
<StatusLine
|
||||
messagesRef={messagesRef}
|
||||
lastAssistantMessageId={lastAssistantMessageId}
|
||||
vimMode={vimMode}
|
||||
/>
|
||||
)}
|
||||
{mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && (
|
||||
<StatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} vimMode={vimMode} />
|
||||
)}
|
||||
<PipeStatusInline />
|
||||
<PromptInputFooterLeftSide
|
||||
exitMessage={exitMessage}
|
||||
vimMode={vimMode}
|
||||
@@ -218,62 +202,215 @@ function PromptInputFooter({
|
||||
isNarrow={isNarrow}
|
||||
/>
|
||||
)}
|
||||
{process.env.USER_TYPE === 'ant' && isUndercover() && (
|
||||
<Text dimColor>undercover</Text>
|
||||
)}
|
||||
{process.env.USER_TYPE === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
|
||||
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
|
||||
</Box>
|
||||
</Box>
|
||||
{process.env.USER_TYPE === 'ant' && <CoordinatorTaskPanel />}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(PromptInputFooter)
|
||||
export default memo(PromptInputFooter);
|
||||
|
||||
type BridgeStatusProps = {
|
||||
bridgeSelected: boolean
|
||||
}
|
||||
bridgeSelected: boolean;
|
||||
};
|
||||
|
||||
function BridgeStatusIndicator({
|
||||
bridgeSelected,
|
||||
}: BridgeStatusProps): React.ReactNode {
|
||||
if (!feature('BRIDGE_MODE')) return null
|
||||
function BridgeStatusIndicator({ bridgeSelected }: BridgeStatusProps): React.ReactNode {
|
||||
if (!feature('BRIDGE_MODE')) return null;
|
||||
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const enabled = useAppState(s => s.replBridgeEnabled)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const connected = useAppState(s => s.replBridgeConnected)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const sessionActive = useAppState(s => s.replBridgeSessionActive)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const reconnecting = useAppState(s => s.replBridgeReconnecting)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
const explicit = useAppState(s => s.replBridgeExplicit)
|
||||
const enabled = useAppState(s => s.replBridgeEnabled);
|
||||
const connected = useAppState(s => s.replBridgeConnected);
|
||||
const sessionActive = useAppState(s => s.replBridgeSessionActive);
|
||||
const reconnecting = useAppState(s => s.replBridgeReconnecting);
|
||||
const explicit = useAppState(s => s.replBridgeExplicit);
|
||||
|
||||
// Failed state is surfaced via notification (useReplBridge), not a footer pill.
|
||||
if (!isBridgeEnabled() || !enabled) return null
|
||||
if (!isBridgeEnabled() || !enabled) return null;
|
||||
|
||||
const status = getBridgeStatus({
|
||||
error: undefined,
|
||||
connected,
|
||||
sessionActive,
|
||||
reconnecting,
|
||||
})
|
||||
});
|
||||
|
||||
// For implicit (config-driven) remote, only show the reconnecting state
|
||||
if (!explicit && status.label !== 'Remote Control reconnecting') {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
color={bridgeSelected ? 'background' : status.color}
|
||||
inverse={bridgeSelected}
|
||||
wrap="truncate"
|
||||
>
|
||||
<Text color={bridgeSelected ? 'background' : status.color} inverse={bridgeSelected} wrap="truncate">
|
||||
{status.label}
|
||||
{bridgeSelected && <Text dimColor> · Enter to view</Text>}
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline pipe status panel with interactive checkbox selection.
|
||||
*
|
||||
* Shows after /pipes sets statusVisible. Displays:
|
||||
* - Header: own pipe info (collapsed mode)
|
||||
* - Ctrl+P: toggle expanded mode with sub list + checkboxes
|
||||
* - Expanded: ↑↓ to move cursor, Space to toggle, Enter/Esc to collapse
|
||||
*
|
||||
* Only uses AppState + Ink — no heavy external imports.
|
||||
*/
|
||||
function PipeStatusInline(): React.ReactNode {
|
||||
if (!feature('UDS_INBOX')) return null;
|
||||
// All hooks must be called before any conditional return to maintain
|
||||
// consistent hook count across renders (React rules of hooks).
|
||||
const pipeIpc = useAppState(s => (s as any).pipeIpc);
|
||||
const setAppState = useSetAppState();
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
|
||||
const isVisible = !!pipeIpc?.statusVisible && !!pipeIpc?.serverName;
|
||||
const selectorOpen: boolean = !!pipeIpc?.selectorOpen;
|
||||
|
||||
const slaves = pipeIpc?.slaves ?? {};
|
||||
const slaveNames = Object.keys(slaves);
|
||||
const discovered: Array<{ pipeName: string; role: string; ip: string; hostname: string }> =
|
||||
pipeIpc?.discoveredPipes ?? [];
|
||||
const allPipes = [...new Set([...slaveNames, ...discovered.map(d => d.pipeName)])].filter(
|
||||
n => n !== pipeIpc?.serverName,
|
||||
);
|
||||
const selectedPipes: string[] = pipeIpc?.selectedPipes ?? [];
|
||||
const displayRole = pipeIpc ? getPipeDisplayRole(pipeIpc) : 'main';
|
||||
const routeMode: 'selected' | 'local' = pipeIpc?.routeMode ?? 'selected';
|
||||
const selectedRouteActive = routeMode !== 'local' && selectedPipes.length > 0;
|
||||
const setRouteMode = (mode: 'selected' | 'local') => {
|
||||
setAppState((prev: any) => {
|
||||
const pIpc = prev.pipeIpc ?? {};
|
||||
return { ...prev, pipeIpc: { ...pIpc, routeMode: mode } };
|
||||
});
|
||||
};
|
||||
|
||||
// Register as modal overlay when selector is open.
|
||||
// This sets isModalOverlayActive=true in PromptInput → TextInput focus=false
|
||||
// → TextInput's useInput is deactivated → ↑↓ no longer trigger history navigation.
|
||||
// Same mechanism used by BackgroundTasksDialog, FuzzyPicker, etc.
|
||||
useRegisterOverlay('pipe-selector', isVisible && selectorOpen);
|
||||
|
||||
// Keyboard handler — must be called every render (hooks rules).
|
||||
// ↑↓ navigate list, Space toggles selection, ←/→ or m switches route mode, Enter/Esc close selector.
|
||||
// No conflict with history nav: useRegisterOverlay above disables TextInput when open.
|
||||
useInput((_input, key) => {
|
||||
if (!isVisible) return;
|
||||
|
||||
// When collapsed: only ←/→ arrow keys toggle route mode (no overlay,
|
||||
// so printable keys like 'm' would leak into the TextInput).
|
||||
// When expanded: ←/→ and 'm' all work (overlay blocks TextInput).
|
||||
if (selectedPipes.length > 0) {
|
||||
const arrowToggle = key.leftArrow || key.rightArrow;
|
||||
const mToggle = selectorOpen && _input.toLowerCase() === 'm';
|
||||
if (arrowToggle || mToggle) {
|
||||
setRouteMode(routeMode === 'local' ? 'selected' : 'local');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectorOpen) return;
|
||||
|
||||
if (key.downArrow) {
|
||||
setCursorIndex(i => Math.min(i + 1, allPipes.length - 1));
|
||||
} else if (key.upArrow) {
|
||||
setCursorIndex(i => Math.max(i - 1, 0));
|
||||
} else if (_input === ' ') {
|
||||
const pipeName = allPipes[cursorIndex];
|
||||
if (pipeName) {
|
||||
setAppState((prev: any) => {
|
||||
const pIpc = prev.pipeIpc ?? {};
|
||||
const sel: string[] = pIpc.selectedPipes ?? [];
|
||||
const newSel = sel.includes(pipeName) ? sel.filter((n: string) => n !== pipeName) : [...sel, pipeName];
|
||||
return { ...prev, pipeIpc: { ...pIpc, selectedPipes: newSel } };
|
||||
});
|
||||
}
|
||||
} else if (key.return || key.escape) {
|
||||
setAppState((prev: any) => {
|
||||
const pIpc = prev.pipeIpc ?? {};
|
||||
return { ...prev, pipeIpc: { ...pIpc, selectorOpen: false } };
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Early return AFTER all hooks
|
||||
if (!isVisible) return null;
|
||||
|
||||
if (!selectorOpen) {
|
||||
return (
|
||||
<Box height={1} gap={1}>
|
||||
<Text dimColor>pipe:</Text>
|
||||
<Text bold>{pipeIpc.serverName}</Text>
|
||||
<Text dimColor>({displayRole})</Text>
|
||||
{pipeIpc.localIp && <Text dimColor>{pipeIpc.localIp}</Text>}
|
||||
{allPipes.length > 0 && (
|
||||
<Text color={selectedRouteActive ? 'success' : undefined} dimColor={selectedPipes.length === 0}>
|
||||
{selectedPipes.length}/{allPipes.length} selected
|
||||
</Text>
|
||||
)}
|
||||
{pipeIpc && isPipeControlled(pipeIpc) && pipeIpc.attachedBy && (
|
||||
<Text color="warning">
|
||||
{'→ '}
|
||||
{pipeIpc.attachedBy}
|
||||
</Text>
|
||||
)}
|
||||
{allPipes.length > 0 && (
|
||||
<Text color={selectedRouteActive ? 'success' : undefined} dimColor={!selectedRouteActive}>
|
||||
{selectedPipes.length > 0
|
||||
? `${routeMode === 'local' ? 'local main' : 'selected pipes only'} · ←/→ switch · Shift+↓ edit`
|
||||
: 'local main · Shift+↓ select'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded mode: header + pipe list with checkboxes
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box height={1} gap={1}>
|
||||
<Text dimColor>pipe:</Text>
|
||||
<Text bold>{pipeIpc.serverName}</Text>
|
||||
<Text dimColor>({displayRole})</Text>
|
||||
{pipeIpc.localIp && <Text dimColor>{pipeIpc.localIp}</Text>}
|
||||
<Text color="warning">↑↓ move Space select ←/→ or m route Enter/Esc close Shift+↓ toggle</Text>
|
||||
</Box>
|
||||
<Box height={1} paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
{selectedPipes.length > 0
|
||||
? `当前普通 prompt 走 ${routeMode === 'local' ? '本地 main' : '已选 sub'};切换不会清空选择`
|
||||
: '当前未选择 pipe;普通 prompt 会在本地 main 对话执行'}
|
||||
</Text>
|
||||
</Box>
|
||||
{allPipes.map((name, idx) => {
|
||||
const isSelected = selectedPipes.includes(name);
|
||||
const isCursor = idx === cursorIndex;
|
||||
const isConnected = !!slaves[name];
|
||||
const disc = discovered.find(d => d.pipeName === name);
|
||||
const label = disc ? `${disc.role} ${disc.hostname}/${disc.ip}` : '';
|
||||
|
||||
return (
|
||||
<Box key={name} height={1} paddingLeft={2}>
|
||||
<Text
|
||||
inverse={isCursor}
|
||||
color={isSelected ? 'success' : isConnected ? undefined : 'error'}
|
||||
dimColor={!isConnected && !isCursor}
|
||||
>
|
||||
{isSelected ? '☑' : '☐'} {name}
|
||||
{isConnected ? '' : ' [offline]'}
|
||||
{label ? ` (${label})` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{allPipes.length === 0 && (
|
||||
<Box height={1} paddingLeft={2}>
|
||||
<Text dimColor>No other pipes found. Start another instance.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const MonitorPermissionRequest: (props: Record<string, unknown>) => null = () => null;
|
||||
@@ -0,0 +1,165 @@
|
||||
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 { truncateToLines } from '../../../utils/stringUtils.js'
|
||||
import { logUnaryEvent } from '../../../utils/unaryLogging.js'
|
||||
import { PermissionDialog } from '../PermissionDialog.js'
|
||||
import {
|
||||
PermissionPrompt,
|
||||
type PermissionPromptOption,
|
||||
} from '../PermissionPrompt.js'
|
||||
import type { PermissionRequestProps } from '../PermissionRequest.js'
|
||||
import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'
|
||||
|
||||
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no'
|
||||
|
||||
/**
|
||||
* Permission request UI for the MonitorTool. Asks the user to confirm
|
||||
* starting a long-running background monitor process.
|
||||
* Follows the FallbackPermissionRequest pattern.
|
||||
*/
|
||||
export function MonitorPermissionRequest({
|
||||
toolUseConfirm,
|
||||
onDone,
|
||||
onReject,
|
||||
workerBadge,
|
||||
}: PermissionRequestProps): React.ReactNode {
|
||||
const [themeName] = useTheme()
|
||||
const theme = getTheme(themeName)
|
||||
|
||||
const input = toolUseConfirm.input as {
|
||||
command: string
|
||||
description: 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="Monitor"
|
||||
workerBadge={workerBadge}
|
||||
>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.permission as any}>
|
||||
{input.description}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{truncateToLines(input.command, 5)}
|
||||
</Text>
|
||||
</Box>
|
||||
<PermissionRuleExplanation
|
||||
permissionResult={toolUseConfirm.permissionResult}
|
||||
toolType="command"
|
||||
/>
|
||||
<PermissionPrompt<OptionValue>
|
||||
options={options}
|
||||
onSelect={handleSelect}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const ReviewArtifactPermissionRequest: (props: Record<string, unknown>) => null = () => null;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { Select } from '../../CustomSelect/select.js'
|
||||
import { usePermissionRequestLogging } from '../hooks.js'
|
||||
import { PermissionDialog } from '../PermissionDialog.js'
|
||||
import type { PermissionRequestProps } from '../PermissionRequest.js'
|
||||
import { logUnaryPermissionEvent } from '../utils.js'
|
||||
|
||||
export function ReviewArtifactPermissionRequest({
|
||||
toolUseConfirm,
|
||||
onDone,
|
||||
onReject,
|
||||
workerBadge,
|
||||
}: PermissionRequestProps): React.ReactNode {
|
||||
const { title, annotations, summary } = toolUseConfirm.input as {
|
||||
title?: string
|
||||
annotations?: Array<{ line?: number; message: string; severity?: string }>
|
||||
summary?: string
|
||||
}
|
||||
|
||||
const unaryEvent = {
|
||||
completion_type: 'tool_use_single' as const,
|
||||
language_name: 'none',
|
||||
}
|
||||
usePermissionRequestLogging(toolUseConfirm, unaryEvent)
|
||||
|
||||
const annotationCount = annotations?.length ?? 0
|
||||
|
||||
function handleResponse(value: 'yes' | 'no'): void {
|
||||
if (value === 'yes') {
|
||||
logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept')
|
||||
toolUseConfirm.onAllow(toolUseConfirm.input, [])
|
||||
onDone()
|
||||
} else {
|
||||
logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'reject')
|
||||
toolUseConfirm.onReject()
|
||||
onReject()
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PermissionDialog
|
||||
color="permission"
|
||||
title="Review artifact?"
|
||||
workerBadge={workerBadge}
|
||||
>
|
||||
<Box flexDirection="column" marginTop={1} paddingX={1}>
|
||||
<Text>
|
||||
Claude wants to review{title ? `: ${title}` : ' an artifact'}.
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor>
|
||||
{annotationCount} annotation{annotationCount !== 1 ? 's' : ''} will
|
||||
be presented.
|
||||
</Text>
|
||||
{summary ? <Text dimColor>Summary: {summary}</Text> : null}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Yes, show review', value: 'yes' as const },
|
||||
{ label: 'No, skip', value: 'no' as const },
|
||||
]}
|
||||
onChange={handleResponse}
|
||||
onCancel={() => handleResponse('no')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</PermissionDialog>
|
||||
)
|
||||
}
|
||||
@@ -528,7 +528,7 @@ export function BackgroundTasksDialog({
|
||||
return (
|
||||
<WorkflowDetailDialog
|
||||
workflow={task}
|
||||
onDone={onDone}
|
||||
onDone={onDone as (message?: string, options?: { display?: string }) => void}
|
||||
onKill={
|
||||
task.status === 'running' && killWorkflowTask
|
||||
? () => killWorkflowTask(task.id, setAppState)
|
||||
@@ -536,12 +536,12 @@ export function BackgroundTasksDialog({
|
||||
}
|
||||
onSkipAgent={
|
||||
task.status === 'running' && skipWorkflowAgent
|
||||
? (agentId: AgentId) => skipWorkflowAgent(task.id, agentId, setAppState)
|
||||
? (agentId: string) => skipWorkflowAgent(task.id, agentId as AgentId, setAppState)
|
||||
: undefined
|
||||
}
|
||||
onRetryAgent={
|
||||
task.status === 'running' && retryWorkflowAgent
|
||||
? (agentId: AgentId) => retryWorkflowAgent(task.id, agentId, setAppState)
|
||||
? (agentId: string) => retryWorkflowAgent(task.id, agentId as AgentId, setAppState)
|
||||
: undefined
|
||||
}
|
||||
onBack={goBackToList}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const MonitorMcpDetailDialog: (props: Record<string, unknown>) => null = () => null;
|
||||
100
src/components/tasks/MonitorMcpDetailDialog.tsx
Normal file
100
src/components/tasks/MonitorMcpDetailDialog.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react'
|
||||
import type { DeepImmutable } from 'src/types/utils.js'
|
||||
import { useElapsedTime } from '../../hooks/useElapsedTime.js'
|
||||
import { Box, Text, type KeyboardEvent } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import type { MonitorMcpTaskState } from '../../tasks/MonitorMcpTask/MonitorMcpTask.js'
|
||||
import { Byline } from '../design-system/Byline.js'
|
||||
import { Dialog } from '../design-system/Dialog.js'
|
||||
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'
|
||||
|
||||
type Props = {
|
||||
task: DeepImmutable<MonitorMcpTaskState>
|
||||
onBack?: () => void
|
||||
onKill?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail dialog for MCP monitor tasks shown in the Shift+Down background
|
||||
* tasks overlay. Displays the server name, resource URI, and current status.
|
||||
* Follows the DreamDetailDialog/ShellDetailDialog pattern.
|
||||
*/
|
||||
export function MonitorMcpDetailDialog({
|
||||
task,
|
||||
onBack,
|
||||
onKill,
|
||||
}: Props): React.ReactNode {
|
||||
const elapsedTime = useElapsedTime(
|
||||
task.startTime,
|
||||
task.status === 'running',
|
||||
1000,
|
||||
0,
|
||||
)
|
||||
|
||||
useKeybindings(
|
||||
{},
|
||||
{ context: 'MonitorMcpDetail' },
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'left' && onBack) {
|
||||
e.preventDefault()
|
||||
onBack()
|
||||
} else if (e.key === 'x' && task.status === 'running' && onKill) {
|
||||
e.preventDefault()
|
||||
onKill()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" tabIndex={0} borderStyle="round" onKeyDown={handleKeyDown}>
|
||||
<Dialog
|
||||
title="MCP Monitor"
|
||||
subtitle={
|
||||
<Text dimColor>
|
||||
{elapsedTime} · {task.serverName}:{task.resourceUri}
|
||||
</Text>
|
||||
}
|
||||
onCancel={onBack ?? (() => {})}
|
||||
inputGuide={() => (
|
||||
<Byline>
|
||||
{onBack && (
|
||||
<KeyboardShortcutHint shortcut="←" action="go back" />
|
||||
)}
|
||||
<KeyboardShortcutHint shortcut="Esc" action="close" />
|
||||
{task.status === 'running' && onKill && (
|
||||
<KeyboardShortcutHint shortcut="x" action="stop" />
|
||||
)}
|
||||
</Byline>
|
||||
)}
|
||||
>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
<Text bold>Status:</Text>{' '}
|
||||
{task.status === 'running' ? (
|
||||
<Text color="ansi:green">running</Text>
|
||||
) : task.status === 'completed' ? (
|
||||
<Text color="ansi:green">{task.status}</Text>
|
||||
) : (
|
||||
<Text color="ansi:red">{task.status}</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Description:</Text> {task.description}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Server:</Text> {task.serverName}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Resource:</Text> {task.resourceUri}
|
||||
</Text>
|
||||
{task.command && (
|
||||
<Text>
|
||||
<Text bold>Command:</Text> {task.command}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const WorkflowDetailDialog: (props: Record<string, unknown>) => null = () => null;
|
||||
115
src/components/tasks/WorkflowDetailDialog.tsx
Normal file
115
src/components/tasks/WorkflowDetailDialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import type { DeepImmutable } from 'src/types/utils.js'
|
||||
import { useElapsedTime } from '../../hooks/useElapsedTime.js'
|
||||
import { Box, Text, type KeyboardEvent } from '@anthropic/ink'
|
||||
import { useKeybindings } from '../../keybindings/useKeybinding.js'
|
||||
import type { LocalWorkflowTaskState } from '../../tasks/LocalWorkflowTask/LocalWorkflowTask.js'
|
||||
import { Byline } from '../design-system/Byline.js'
|
||||
import { Dialog } from '../design-system/Dialog.js'
|
||||
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'
|
||||
|
||||
type Props = {
|
||||
workflow: DeepImmutable<LocalWorkflowTaskState>
|
||||
onDone: (message?: string, options?: { display?: string }) => void
|
||||
onKill?: () => void
|
||||
onSkipAgent?: (agentId: string) => void
|
||||
onRetryAgent?: (agentId: string) => void
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail dialog for local workflow tasks shown in the Shift+Down background
|
||||
* tasks overlay. Displays the workflow name, file, status, and output.
|
||||
* Follows the DreamDetailDialog/ShellDetailDialog pattern.
|
||||
*/
|
||||
export function WorkflowDetailDialog({
|
||||
workflow,
|
||||
onDone,
|
||||
onKill,
|
||||
onSkipAgent: _onSkipAgent,
|
||||
onRetryAgent: _onRetryAgent,
|
||||
onBack,
|
||||
}: Props): React.ReactNode {
|
||||
const elapsedTime = useElapsedTime(
|
||||
workflow.startTime,
|
||||
workflow.status === 'running',
|
||||
1000,
|
||||
0,
|
||||
)
|
||||
|
||||
useKeybindings(
|
||||
{},
|
||||
{ context: 'WorkflowDetail' },
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent): void => {
|
||||
if (e.key === 'left' && onBack) {
|
||||
e.preventDefault()
|
||||
onBack()
|
||||
} else if (e.key === 'x' && workflow.status === 'running' && onKill) {
|
||||
e.preventDefault()
|
||||
onKill()
|
||||
}
|
||||
},
|
||||
[onBack, onKill, workflow.status],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" tabIndex={0} borderStyle="round" onKeyDown={handleKeyDown}>
|
||||
<Dialog
|
||||
title="Workflow"
|
||||
subtitle={
|
||||
<Text dimColor>
|
||||
{elapsedTime} · {workflow.workflowName}
|
||||
</Text>
|
||||
}
|
||||
onCancel={onBack ?? (() => {})}
|
||||
inputGuide={() => (
|
||||
<Byline>
|
||||
{onBack && (
|
||||
<KeyboardShortcutHint shortcut={'\u2190'} action="go back" />
|
||||
)}
|
||||
<KeyboardShortcutHint shortcut="Esc" action="close" />
|
||||
{workflow.status === 'running' && onKill && (
|
||||
<KeyboardShortcutHint shortcut="x" action="stop" />
|
||||
)}
|
||||
</Byline>
|
||||
)}
|
||||
>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
<Text bold>Status:</Text>{' '}
|
||||
{workflow.status === 'running' ? (
|
||||
<Text color="ansi:green">running</Text>
|
||||
) : workflow.status === 'completed' ? (
|
||||
<Text color="ansi:green">{workflow.status}</Text>
|
||||
) : (
|
||||
<Text color="ansi:red">{workflow.status}</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Description:</Text> {workflow.description}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>Workflow:</Text> {workflow.workflowName}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold>File:</Text> {workflow.workflowFile}
|
||||
</Text>
|
||||
{workflow.summary && (
|
||||
<Text>
|
||||
<Text bold>Summary:</Text> {workflow.summary}
|
||||
</Text>
|
||||
)}
|
||||
{workflow.output && (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>Output:</Text>
|
||||
<Text dimColor>{workflow.output}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,67 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js';
|
||||
export const getCoordinatorAgents: () => AgentDefinition[] = () => [];
|
||||
/**
|
||||
* Coordinator-mode worker agent definition.
|
||||
*
|
||||
* When COORDINATOR_MODE is active, getBuiltInAgents() returns only
|
||||
* the agents from getCoordinatorAgents(). The coordinator's system
|
||||
* prompt instructs it to use `subagent_type: "worker"` when spawning
|
||||
* tasks via the Agent tool.
|
||||
*
|
||||
* Workers get the full standard tool set (minus internal orchestration
|
||||
* tools like TeamCreate/SendMessage) so they can research, implement,
|
||||
* and verify autonomously.
|
||||
*/
|
||||
import { ASYNC_AGENT_ALLOWED_TOOLS } from '../constants/tools.js'
|
||||
import { SEND_MESSAGE_TOOL_NAME } from '../tools/SendMessageTool/constants.js'
|
||||
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
||||
import { TEAM_CREATE_TOOL_NAME } from '../tools/TeamCreateTool/constants.js'
|
||||
import { TEAM_DELETE_TOOL_NAME } from '../tools/TeamDeleteTool/constants.js'
|
||||
import type { BuiltInAgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
|
||||
|
||||
/**
|
||||
* Tools that workers must NOT have — these are coordinator-only
|
||||
* orchestration primitives.
|
||||
*/
|
||||
const INTERNAL_ORCHESTRATION_TOOLS = new Set([
|
||||
TEAM_CREATE_TOOL_NAME,
|
||||
TEAM_DELETE_TOOL_NAME,
|
||||
SEND_MESSAGE_TOOL_NAME,
|
||||
SYNTHETIC_OUTPUT_TOOL_NAME,
|
||||
])
|
||||
|
||||
/**
|
||||
* Build the worker's allowed tool list from ASYNC_AGENT_ALLOWED_TOOLS,
|
||||
* excluding internal orchestration tools.
|
||||
*/
|
||||
function getWorkerTools(): string[] {
|
||||
return Array.from(ASYNC_AGENT_ALLOWED_TOOLS).filter(
|
||||
name => !INTERNAL_ORCHESTRATION_TOOLS.has(name),
|
||||
)
|
||||
}
|
||||
|
||||
const WORKER_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'worker',
|
||||
whenToUse:
|
||||
'Worker agent for coordinator mode. Executes research, implementation, and verification tasks autonomously with the full standard tool set.',
|
||||
tools: getWorkerTools(),
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
getSystemPrompt: () =>
|
||||
`You are a worker agent spawned by a coordinator. Your job is to complete the task described in the prompt thoroughly and report back with a concise summary of what you did and what you found.
|
||||
|
||||
Guidelines:
|
||||
- Complete the task fully — don't leave it half-done, but don't gold-plate either.
|
||||
- Use tools proactively: read files, search code, run commands, edit files.
|
||||
- Be thorough in research: check multiple locations, consider different naming conventions.
|
||||
- For implementation: make targeted changes, run tests to verify, commit if appropriate.
|
||||
- Report back with actionable findings — the coordinator will synthesize your results.
|
||||
- If you encounter errors, investigate and attempt to fix them before reporting failure.
|
||||
- NEVER create documentation files unless explicitly instructed.`,
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the agent definitions available in coordinator mode.
|
||||
* Called by getBuiltInAgents() when COORDINATOR_MODE is active.
|
||||
*/
|
||||
export function getCoordinatorAgents(): BuiltInAgentDefinition[] {
|
||||
return [WORKER_AGENT]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
import { feature } from 'bun:bundle'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
|
||||
// Runtime fallback for MACRO.* when not injected by build/dev defines.
|
||||
// This happens when running cli.tsx directly (not via `bun run dev` or built dist/).
|
||||
@@ -15,6 +16,21 @@ if (typeof globalThis.MACRO === 'undefined') {
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE)) {
|
||||
for (const stream of [process.stdin, process.stdout, process.stderr]) {
|
||||
if (!stream.isTTY) {
|
||||
try {
|
||||
Object.defineProperty(stream, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
})
|
||||
} catch {
|
||||
// Best-effort dev-only override for nested bun launch on Windows.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||
process.env.COREPACK_ENABLE_AUTO_PIN = '0'
|
||||
|
||||
116
src/hooks/__tests__/useMasterMonitor.test.ts
Normal file
116
src/hooks/__tests__/useMasterMonitor.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
addSlaveClient,
|
||||
applyPipeEntryToSlaveState,
|
||||
getConnectedSlaveTargets,
|
||||
resetSlaveClientsForTesting,
|
||||
subscribePipeEntries,
|
||||
} from '../useMasterMonitor.js'
|
||||
|
||||
afterEach(() => {
|
||||
resetSlaveClientsForTesting()
|
||||
})
|
||||
|
||||
describe('useMasterMonitor registry helpers', () => {
|
||||
test('returns only attached and connected targets from a selection list', () => {
|
||||
addSlaveClient('cli-a', { connected: true } as any)
|
||||
addSlaveClient('cli-b', { connected: false } as any)
|
||||
|
||||
const targets = getConnectedSlaveTargets(['cli-a', 'cli-b', 'cli-c'])
|
||||
|
||||
expect(targets).toHaveLength(1)
|
||||
expect(targets[0]?.name).toBe('cli-a')
|
||||
expect(targets[0]?.client.connected).toBe(true)
|
||||
})
|
||||
|
||||
test('returns an empty array when no selected targets are connected', () => {
|
||||
addSlaveClient('cli-a', { connected: false } as any)
|
||||
|
||||
expect(getConnectedSlaveTargets(['cli-a', 'cli-missing'])).toEqual([])
|
||||
})
|
||||
|
||||
test('applies prompt_ack as busy activity with a summary', () => {
|
||||
const next = applyPipeEntryToSlaveState(
|
||||
{
|
||||
name: 'cli-a',
|
||||
connectedAt: '2026-04-08T00:00:00.000Z',
|
||||
status: 'idle',
|
||||
unreadCount: 0,
|
||||
history: [],
|
||||
},
|
||||
{
|
||||
type: 'prompt_ack',
|
||||
content: 'accepted',
|
||||
from: 'cli-a',
|
||||
timestamp: '2026-04-08T00:00:01.000Z',
|
||||
},
|
||||
)
|
||||
|
||||
expect(next.status).toBe('busy')
|
||||
expect(next.lastEventType).toBe('prompt_ack')
|
||||
expect(next.lastSummary).toBe('accepted')
|
||||
expect(next.unreadCount).toBe(1)
|
||||
})
|
||||
|
||||
test('applies done and error entries to terminal slave states', () => {
|
||||
const doneState = applyPipeEntryToSlaveState(
|
||||
{
|
||||
name: 'cli-a',
|
||||
connectedAt: '2026-04-08T00:00:00.000Z',
|
||||
status: 'busy',
|
||||
unreadCount: 1,
|
||||
history: [],
|
||||
},
|
||||
{
|
||||
type: 'done',
|
||||
content: 'completed',
|
||||
from: 'cli-a',
|
||||
timestamp: '2026-04-08T00:00:02.000Z',
|
||||
},
|
||||
)
|
||||
|
||||
expect(doneState.status).toBe('idle')
|
||||
expect(doneState.lastSummary).toBe('completed')
|
||||
|
||||
const errorState = applyPipeEntryToSlaveState(doneState, {
|
||||
type: 'error',
|
||||
content: 'failed',
|
||||
from: 'cli-a',
|
||||
timestamp: '2026-04-08T00:00:03.000Z',
|
||||
})
|
||||
|
||||
expect(errorState.status).toBe('error')
|
||||
expect(errorState.lastEventType).toBe('error')
|
||||
expect(errorState.lastSummary).toBe('failed')
|
||||
expect(errorState.unreadCount).toBe(3)
|
||||
})
|
||||
|
||||
test('emits pipe entries immediately when connected clients receive messages', () => {
|
||||
const handlers = new Map<string, (msg: any) => void>()
|
||||
const client = {
|
||||
connected: true,
|
||||
on(event: string, handler: (msg: any) => void) {
|
||||
handlers.set(event, handler)
|
||||
},
|
||||
removeListener(event: string) {
|
||||
handlers.delete(event)
|
||||
},
|
||||
}
|
||||
const seen: Array<{ name: string; type: string; content: string }> = []
|
||||
const unsubscribe = subscribePipeEntries((name, entry) => {
|
||||
seen.push({ name, type: entry.type, content: entry.content })
|
||||
})
|
||||
|
||||
addSlaveClient('cli-a', client as any)
|
||||
handlers.get('message')?.({
|
||||
type: 'stream',
|
||||
data: 'hello',
|
||||
from: 'cli-a',
|
||||
ts: '2026-04-08T00:00:04.000Z',
|
||||
})
|
||||
|
||||
expect(seen).toEqual([{ name: 'cli-a', type: 'stream', content: 'hello' }])
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { randomUUID } from 'crypto'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { getAllowedChannels } from '../../../bootstrap/state.js'
|
||||
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
|
||||
import type { ToolUseConfirm } from '../../../components/permissions/PermissionRequest.js'
|
||||
import { getTerminalFocused } from '@anthropic/ink'
|
||||
import {
|
||||
CHANNEL_PERMISSION_REQUEST_METHOD,
|
||||
@@ -25,6 +26,11 @@ import {
|
||||
setYoloClassifierApproval,
|
||||
} from '../../../utils/classifierApprovals.js'
|
||||
import { errorMessage } from '../../../utils/errors.js'
|
||||
import {
|
||||
forgetPipePermissionRequest,
|
||||
notifyPipePermissionCancel,
|
||||
tryRelayPipePermissionRequest,
|
||||
} from '../../../utils/pipePermissionRelay.js'
|
||||
import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'
|
||||
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import { hasPermissionsToUseTool } from '../../../utils/permissions/permissions.js'
|
||||
@@ -82,6 +88,18 @@ function handleInteractivePermission(
|
||||
|
||||
const permissionPromptStartTimeMs = Date.now()
|
||||
const displayInput = result.updatedInput ?? ctx.input
|
||||
let pipePermissionRequestId: string | null = null
|
||||
|
||||
function forgetPipePermission(reason?: string): void {
|
||||
notifyPipePermissionCancel(pipePermissionRequestId, reason)
|
||||
forgetPipePermissionRequest(pipePermissionRequestId)
|
||||
pipePermissionRequestId = null
|
||||
}
|
||||
|
||||
function forgetPipePermissionSilently(): void {
|
||||
forgetPipePermissionRequest(pipePermissionRequestId)
|
||||
pipePermissionRequestId = null
|
||||
}
|
||||
|
||||
function clearClassifierIndicator(): void {
|
||||
if (feature('BASH_CLASSIFIER')) {
|
||||
@@ -89,7 +107,7 @@ function handleInteractivePermission(
|
||||
}
|
||||
}
|
||||
|
||||
ctx.pushToQueue({
|
||||
const toolUseConfirm: ToolUseConfirm = {
|
||||
assistantMessage: ctx.assistantMessage,
|
||||
tool: ctx.tool,
|
||||
description,
|
||||
@@ -136,6 +154,7 @@ function handleInteractivePermission(
|
||||
},
|
||||
onAbort() {
|
||||
if (!claim()) return
|
||||
forgetPipePermission('Permission request was aborted locally in sub.')
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||
behavior: 'deny',
|
||||
@@ -158,6 +177,7 @@ function handleInteractivePermission(
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
) {
|
||||
if (!claim()) return // atomic check-and-mark before await
|
||||
forgetPipePermission('Permission request was approved locally in sub.')
|
||||
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||
@@ -182,6 +202,7 @@ function handleInteractivePermission(
|
||||
},
|
||||
onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) {
|
||||
if (!claim()) return
|
||||
forgetPipePermission('Permission request was rejected locally in sub.')
|
||||
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||
@@ -220,6 +241,7 @@ function handleInteractivePermission(
|
||||
// a CCR-initiated mode switch, the very case this callback exists
|
||||
// for after useReplBridge started calling it).
|
||||
if (!claim()) return
|
||||
forgetPipePermission('Permission request was resolved locally in sub.')
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
@@ -229,7 +251,65 @@ function handleInteractivePermission(
|
||||
resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ctx.pushToQueue(toolUseConfirm)
|
||||
pipePermissionRequestId = tryRelayPipePermissionRequest(
|
||||
toolUseConfirm,
|
||||
response => {
|
||||
if (!claim()) return
|
||||
forgetPipePermissionSilently()
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
ctx.removeFromQueue()
|
||||
channelUnsubscribe?.()
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
|
||||
if (response.behavior === 'allow') {
|
||||
void (async () => {
|
||||
if (response.permissionUpdates?.length) {
|
||||
void ctx.persistPermissions(response.permissionUpdates)
|
||||
}
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: {
|
||||
type: 'user',
|
||||
permanent: !!response.permissionUpdates?.length,
|
||||
},
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(
|
||||
ctx.buildAllow(response.updatedInput ?? displayInput, {
|
||||
acceptFeedback: response.feedback,
|
||||
contentBlocks: response.contentBlocks,
|
||||
}),
|
||||
)
|
||||
})()
|
||||
} else {
|
||||
ctx.logDecision(
|
||||
{
|
||||
decision: 'reject',
|
||||
source: {
|
||||
type: 'user_reject',
|
||||
hasFeedback: !!response.feedback,
|
||||
},
|
||||
},
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
resolveOnce(
|
||||
ctx.cancelAndAbort(
|
||||
response.feedback,
|
||||
undefined,
|
||||
response.contentBlocks,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Race 4: Bridge permission response from CCR (claude.ai)
|
||||
// When the bridge is connected, send the permission request to CCR and
|
||||
@@ -257,6 +337,9 @@ function handleInteractivePermission(
|
||||
bridgeRequestId,
|
||||
response => {
|
||||
if (!claim()) return // Local user/hook/classifier already responded
|
||||
forgetPipePermission(
|
||||
'Permission request was resolved by bridge before pipe response.',
|
||||
)
|
||||
signal.removeEventListener('abort', unsubscribe)
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
@@ -364,6 +447,9 @@ function handleInteractivePermission(
|
||||
channelRequestId,
|
||||
response => {
|
||||
if (!claim()) return // Another racer won
|
||||
forgetPipePermission(
|
||||
'Permission request was resolved by channel before pipe response.',
|
||||
)
|
||||
channelUnsubscribe?.() // both: map delete + listener remove
|
||||
clearClassifierChecking(ctx.toolUseID)
|
||||
clearClassifierIndicator()
|
||||
@@ -421,6 +507,9 @@ function handleInteractivePermission(
|
||||
permissionPromptStartTimeMs,
|
||||
)
|
||||
if (!hookDecision || !claim()) return
|
||||
forgetPipePermission(
|
||||
'Permission request was resolved by hook before pipe response.',
|
||||
)
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
@@ -453,6 +542,9 @@ function handleInteractivePermission(
|
||||
},
|
||||
onAllow: decisionReason => {
|
||||
if (!claim()) return
|
||||
forgetPipePermission(
|
||||
'Permission request was auto-approved before pipe response.',
|
||||
)
|
||||
if (bridgeCallbacks && bridgeRequestId) {
|
||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||
}
|
||||
|
||||
@@ -61,15 +61,18 @@ function stepTeammateSelection(
|
||||
* Custom hook that handles Shift+Up/Down keyboard navigation for background tasks.
|
||||
* When teammates (swarm) are present, navigates between leader and teammates.
|
||||
* When only non-teammate background tasks exist, opens the background tasks dialog.
|
||||
* When pipe IPC is active (UDS_INBOX), Shift+Down toggles the pipe selector panel.
|
||||
* Also handles Enter to confirm selection, 'f' to view transcript, and 'k' to kill.
|
||||
*/
|
||||
export function useBackgroundTaskNavigation(options?: {
|
||||
onOpenBackgroundTasks?: () => void
|
||||
onTogglePipeSelector?: () => void
|
||||
}): { handleKeyDown: (e: KeyboardEvent) => void } {
|
||||
const tasks = useAppState(s => s.tasks)
|
||||
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
|
||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
||||
const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex)
|
||||
const pipeIpc = useAppState(s => (s as any).pipeIpc)
|
||||
const setAppState = useSetAppState()
|
||||
|
||||
// Filter to running teammates and sort alphabetically to match TeammateSpinnerTree display
|
||||
@@ -177,12 +180,20 @@ export function useBackgroundTaskNavigation(options?: {
|
||||
// Shift+Up/Down for teammate transcript switching (with wrapping)
|
||||
// Index -1 represents the leader, 0+ are teammates
|
||||
// When showSpinnerTree is true, index === teammateCount is the "hide" row
|
||||
// Third case: when pipe IPC is active and no teammates/background tasks, toggle pipe selector
|
||||
if (e.shift && (e.key === 'up' || e.key === 'down')) {
|
||||
e.preventDefault()
|
||||
if (teammateCount > 0) {
|
||||
stepTeammateSelection(e.key === 'down' ? 1 : -1, setAppState)
|
||||
} else if (hasNonTeammateBackgroundTasks) {
|
||||
options?.onOpenBackgroundTasks?.()
|
||||
} else if (
|
||||
e.key === 'down' &&
|
||||
pipeIpc?.statusVisible &&
|
||||
options?.onTogglePipeSelector
|
||||
) {
|
||||
// Shift+Down opens pipe selector when pipe IPC is active and no other navigation targets
|
||||
options.onTogglePipeSelector()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
327
src/hooks/useMasterMonitor.ts
Normal file
327
src/hooks/useMasterMonitor.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* useMasterMonitor — master-side slave registry helpers plus an optional hook
|
||||
*
|
||||
* The module-level registry helpers are the live integration point used by
|
||||
* attach/send/status flows. The hook remains available for history syncing if
|
||||
* a caller wants AppState to mirror slave session events.
|
||||
*
|
||||
* The master CLI itself remains fully functional — this hook only collects
|
||||
* data from slaves for review via /history and /status commands.
|
||||
*/
|
||||
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import {
|
||||
getPipeIpc,
|
||||
type PipeClient,
|
||||
type PipeMessage,
|
||||
type PipeIpcSlaveState,
|
||||
} from '../utils/pipeTransport.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
|
||||
/** Session history entry for pipe IPC monitoring. */
|
||||
export type SessionEntry = {
|
||||
type: string
|
||||
content: string
|
||||
from: string
|
||||
timestamp: string
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function summarizePipeEntry(entry: SessionEntry): string | undefined {
|
||||
const content = entry.content.trim()
|
||||
switch (entry.type) {
|
||||
case 'prompt':
|
||||
return content ? `Queued: ${content}` : 'Queued prompt'
|
||||
case 'prompt_ack':
|
||||
return content || 'Accepted'
|
||||
case 'stream':
|
||||
return content || undefined
|
||||
case 'tool_start':
|
||||
return content ? `Tool: ${content}` : 'Tool started'
|
||||
case 'tool_result':
|
||||
return content ? `Tool result: ${content}` : 'Tool completed'
|
||||
case 'done':
|
||||
return content || 'Completed'
|
||||
case 'error':
|
||||
return content || 'Error'
|
||||
default:
|
||||
return content || undefined
|
||||
}
|
||||
}
|
||||
|
||||
function statusForPipeEntry(
|
||||
currentStatus: PipeIpcSlaveState['status'],
|
||||
entryType: SessionEntry['type'],
|
||||
): PipeIpcSlaveState['status'] {
|
||||
switch (entryType) {
|
||||
case 'prompt':
|
||||
case 'prompt_ack':
|
||||
case 'stream':
|
||||
case 'tool_start':
|
||||
case 'tool_result':
|
||||
return 'busy'
|
||||
case 'done':
|
||||
return 'idle'
|
||||
case 'error':
|
||||
return 'error'
|
||||
default:
|
||||
return currentStatus
|
||||
}
|
||||
}
|
||||
|
||||
export function applyPipeEntryToSlaveState(
|
||||
slave: PipeIpcSlaveState,
|
||||
entry: SessionEntry,
|
||||
): PipeIpcSlaveState {
|
||||
return {
|
||||
...slave,
|
||||
status: statusForPipeEntry(slave.status, entry.type),
|
||||
lastActivityAt: entry.timestamp,
|
||||
lastSummary: summarizePipeEntry(entry),
|
||||
lastEventType: entry.type as PipeIpcSlaveState['lastEventType'],
|
||||
unreadCount: (slave.unreadCount ?? 0) + 1,
|
||||
history: [...slave.history, entry],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level registry of connected slave PipeClients.
|
||||
* Keyed by slave pipe name. Managed by /attach and /detach commands.
|
||||
*/
|
||||
const _slaveClients = new Map<string, PipeClient>()
|
||||
const _slaveClientRegistryListeners = new Set<() => void>()
|
||||
const _pipeEntryListeners = new Set<
|
||||
(slaveName: string, entry: SessionEntry) => void
|
||||
>()
|
||||
const _pipeEntryHandlers = new Map<string, (msg: PipeMessage) => void>()
|
||||
let _slaveClientRegistryVersion = 0
|
||||
|
||||
const MONITORED_PIPE_ENTRY_TYPES = [
|
||||
'prompt_ack',
|
||||
'stream',
|
||||
'tool_start',
|
||||
'tool_result',
|
||||
'done',
|
||||
'error',
|
||||
'prompt',
|
||||
'permission_request',
|
||||
'permission_cancel',
|
||||
]
|
||||
|
||||
function isMonitoredPipeEntryType(type: string): boolean {
|
||||
return MONITORED_PIPE_ENTRY_TYPES.includes(type)
|
||||
}
|
||||
|
||||
function pipeMessageToSessionEntry(
|
||||
slaveName: string,
|
||||
msg: PipeMessage,
|
||||
): SessionEntry {
|
||||
return {
|
||||
type: msg.type as SessionEntry['type'],
|
||||
content: msg.data ?? '',
|
||||
from: msg.from ?? slaveName,
|
||||
timestamp: msg.ts ?? new Date().toISOString(),
|
||||
meta: msg.meta,
|
||||
}
|
||||
}
|
||||
|
||||
function emitPipeEntry(slaveName: string, entry: SessionEntry): void {
|
||||
for (const listener of _pipeEntryListeners) {
|
||||
listener(slaveName, entry)
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribePipeEntries(
|
||||
listener: (slaveName: string, entry: SessionEntry) => void,
|
||||
): () => void {
|
||||
_pipeEntryListeners.add(listener)
|
||||
return () => {
|
||||
_pipeEntryListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function detachPipeEntryEmitter(name: string, client?: PipeClient): void {
|
||||
const handler = _pipeEntryHandlers.get(name)
|
||||
if (!handler) return
|
||||
client?.removeListener?.('message', handler)
|
||||
_pipeEntryHandlers.delete(name)
|
||||
}
|
||||
|
||||
function attachPipeEntryEmitter(name: string, client: PipeClient): void {
|
||||
detachPipeEntryEmitter(name, _slaveClients.get(name))
|
||||
if (typeof client.on !== 'function') return
|
||||
const handler = (msg: PipeMessage) => {
|
||||
if (!isMonitoredPipeEntryType(msg.type)) return
|
||||
emitPipeEntry(name, pipeMessageToSessionEntry(name, msg))
|
||||
}
|
||||
_pipeEntryHandlers.set(name, handler)
|
||||
client.on('message', handler)
|
||||
}
|
||||
|
||||
function emitSlaveClientRegistryChanged(): void {
|
||||
_slaveClientRegistryVersion += 1
|
||||
for (const listener of _slaveClientRegistryListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToSlaveClientRegistry(listener: () => void): () => void {
|
||||
_slaveClientRegistryListeners.add(listener)
|
||||
return () => {
|
||||
_slaveClientRegistryListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function getSlaveClientRegistryVersion(): number {
|
||||
return _slaveClientRegistryVersion
|
||||
}
|
||||
|
||||
export function addSlaveClient(name: string, client: PipeClient): void {
|
||||
attachPipeEntryEmitter(name, client)
|
||||
_slaveClients.set(name, client)
|
||||
emitSlaveClientRegistryChanged()
|
||||
}
|
||||
|
||||
export function removeSlaveClient(name: string): PipeClient | undefined {
|
||||
const client = _slaveClients.get(name)
|
||||
detachPipeEntryEmitter(name, client)
|
||||
_slaveClients.delete(name)
|
||||
emitSlaveClientRegistryChanged()
|
||||
return client
|
||||
}
|
||||
|
||||
export function getSlaveClient(name: string): PipeClient | undefined {
|
||||
return _slaveClients.get(name)
|
||||
}
|
||||
|
||||
export function getAllSlaveClients(): Map<string, PipeClient> {
|
||||
return _slaveClients
|
||||
}
|
||||
|
||||
export type ConnectedSlaveTarget = {
|
||||
name: string
|
||||
client: PipeClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a selection list to currently connected slave clients.
|
||||
*
|
||||
* The pipe selector can include discovered-but-not-attached names. Routing
|
||||
* should only treat attached, connected clients as broadcast targets.
|
||||
*/
|
||||
export function getConnectedSlaveTargets(
|
||||
selectedNames: string[],
|
||||
): ConnectedSlaveTarget[] {
|
||||
const targets: ConnectedSlaveTarget[] = []
|
||||
for (const name of selectedNames) {
|
||||
const client = _slaveClients.get(name)
|
||||
if (client?.connected) {
|
||||
targets.push({ name, client })
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
export function resetSlaveClientsForTesting(): void {
|
||||
for (const [name, client] of _slaveClients.entries()) {
|
||||
detachPipeEntryEmitter(name, client)
|
||||
}
|
||||
_slaveClients.clear()
|
||||
emitSlaveClientRegistryChanged()
|
||||
}
|
||||
|
||||
export function useMasterMonitor(): void {
|
||||
const role = useAppState(s => getPipeIpc(s).role)
|
||||
const setAppState = useSetAppState()
|
||||
const registryVersion = useSyncExternalStore(
|
||||
subscribeToSlaveClientRegistry,
|
||||
getSlaveClientRegistryVersion,
|
||||
getSlaveClientRegistryVersion,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (role !== 'master' && _slaveClients.size === 0) return
|
||||
|
||||
// Set up listeners for each connected slave client
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
for (const [slaveName, client] of _slaveClients.entries()) {
|
||||
const handler = (msg: PipeMessage) => {
|
||||
const entry = pipeMessageToSessionEntry(slaveName, msg)
|
||||
|
||||
// Only record relevant message types
|
||||
if (!isMonitoredPipeEntryType(msg.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
setAppState(prev => {
|
||||
const slave = getPipeIpc(prev).slaves[slaveName]
|
||||
if (!slave) return prev
|
||||
|
||||
const newStatus =
|
||||
msg.type === 'done' || msg.type === 'error'
|
||||
? 'idle'
|
||||
: msg.type === 'prompt'
|
||||
? 'busy'
|
||||
: slave.status
|
||||
|
||||
return {
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...getPipeIpc(prev),
|
||||
slaves: {
|
||||
...getPipeIpc(prev).slaves,
|
||||
[slaveName]: applyPipeEntryToSlaveState(
|
||||
{
|
||||
...slave,
|
||||
status: newStatus,
|
||||
},
|
||||
entry,
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if (msg.type === 'done') {
|
||||
logForDebugging(`[MasterMonitor] Slave "${slaveName}" turn complete`)
|
||||
}
|
||||
}
|
||||
|
||||
client.on('message', handler)
|
||||
|
||||
// Handle slave disconnect
|
||||
const onDisconnect = () => {
|
||||
logForDebugging(`[MasterMonitor] Slave "${slaveName}" disconnected`)
|
||||
removeSlaveClient(slaveName)
|
||||
setAppState(prev => {
|
||||
const { [slaveName]: _removed, ...remainingSlaves } =
|
||||
getPipeIpc(prev).slaves
|
||||
const hasSlaves = Object.keys(remainingSlaves).length > 0
|
||||
return {
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...getPipeIpc(prev),
|
||||
role: hasSlaves ? 'master' : 'main',
|
||||
displayRole: hasSlaves ? 'master' : 'main',
|
||||
slaves: remainingSlaves,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
client.on('disconnect', onDisconnect)
|
||||
cleanups.push(() => {
|
||||
client.removeListener('message', handler)
|
||||
client.removeListener('disconnect', onDisconnect)
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}, [registryVersion, role, setAppState])
|
||||
}
|
||||
623
src/hooks/usePipeIpc.ts
Normal file
623
src/hooks/usePipeIpc.ts
Normal file
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* usePipeIpc — Pipe IPC lifecycle hook.
|
||||
*
|
||||
* Extracted from REPL.tsx's 575-line inline useEffect. Manages:
|
||||
* 1. Server creation (UDS + optional TCP for LAN)
|
||||
* 2. LAN beacon startup
|
||||
* 3. Message handlers (ping, attach, prompt, permission, detach)
|
||||
* 4. Heartbeat loop (main: auto-attach + cleanup; sub: detect main alive)
|
||||
* 5. Cleanup on unmount
|
||||
*
|
||||
* Feature-gated by UDS_INBOX. LAN extensions gated by LAN_PIPES.
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import { useEffect } from 'react'
|
||||
import type {
|
||||
PipeMessage,
|
||||
PipeServer,
|
||||
PipeIpcState,
|
||||
} from '../utils/pipeTransport.js'
|
||||
|
||||
// Lazy-loaded module accessors (cached by Bun/Node require)
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const pt = () =>
|
||||
require('../utils/pipeTransport.js') as typeof import('../utils/pipeTransport.js')
|
||||
const pr = () =>
|
||||
require('../utils/pipeRegistry.js') as typeof import('../utils/pipeRegistry.js')
|
||||
const mm = () =>
|
||||
require('./useMasterMonitor.js') as typeof import('./useMasterMonitor.js')
|
||||
const bs = () =>
|
||||
require('../bootstrap/state.js') as typeof import('../bootstrap/state.js')
|
||||
const lb = () =>
|
||||
require('../utils/lanBeacon.js') as typeof import('../utils/lanBeacon.js')
|
||||
const pp = () =>
|
||||
require('../utils/pipePermissionRelay.js') as typeof import('../utils/pipePermissionRelay.js')
|
||||
const osm = () => require('os') as typeof import('os')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type StoreApi = {
|
||||
getState: () => any
|
||||
setState: (updater: (prev: any) => any) => void
|
||||
}
|
||||
|
||||
export type UsePipeIpcOptions = {
|
||||
store: StoreApi
|
||||
handleIncomingPrompt: (content: string) => boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: remove a dead slave from registry + state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function removeDeadSlave(slaveName: string, store: StoreApi): void {
|
||||
mm().removeSlaveClient(slaveName)
|
||||
store.setState((prev: any) => {
|
||||
const pipeIpc = pt().getPipeIpc(prev)
|
||||
const { [slaveName]: _removed, ...remainingSlaves } = pipeIpc.slaves
|
||||
return {
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...pipeIpc,
|
||||
role: Object.keys(remainingSlaves).length > 0 ? 'master' : 'main',
|
||||
displayRole:
|
||||
Object.keys(remainingSlaves).length > 0 ? 'master' : 'main',
|
||||
slaves: remainingSlaves,
|
||||
selectedPipes: (pipeIpc.selectedPipes ?? []).filter(
|
||||
(name: string) => name !== slaveName,
|
||||
),
|
||||
discoveredPipes: (pipeIpc.discoveredPipes ?? []).filter(
|
||||
(pipe: { pipeName: string }) => pipe.pipeName !== slaveName,
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: refresh discovered pipes (local subs + LAN peers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function refreshDiscoveredPipes(
|
||||
pipeName: string,
|
||||
aliveSubs: Array<{
|
||||
id: string
|
||||
pipeName: string
|
||||
subIndex: number
|
||||
machineId: string
|
||||
ip: string
|
||||
hostname: string
|
||||
}>,
|
||||
store: StoreApi,
|
||||
): void {
|
||||
const freshDiscovered = aliveSubs
|
||||
.filter(sub => sub.pipeName !== pipeName)
|
||||
.map(sub => ({
|
||||
id: sub.id,
|
||||
pipeName: sub.pipeName,
|
||||
role: `sub-${sub.subIndex}`,
|
||||
machineId: sub.machineId,
|
||||
ip: sub.ip,
|
||||
hostname: sub.hostname,
|
||||
alive: true,
|
||||
}))
|
||||
|
||||
// Include LAN beacon peers so they aren't wiped out by heartbeat
|
||||
let lanDiscovered: typeof freshDiscovered = []
|
||||
if (feature('LAN_PIPES')) {
|
||||
const beacon = lb().getLanBeacon()
|
||||
if (beacon) {
|
||||
const localNames = new Set(freshDiscovered.map(p => p.pipeName))
|
||||
localNames.add(pipeName)
|
||||
for (const [pName, peer] of beacon.getPeers()) {
|
||||
if (!localNames.has(pName)) {
|
||||
lanDiscovered.push({
|
||||
id: `lan-${pName}`,
|
||||
pipeName: pName,
|
||||
role: peer.role,
|
||||
machineId: peer.machineId,
|
||||
ip: peer.ip,
|
||||
hostname: peer.hostname,
|
||||
alive: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allDiscovered = [...freshDiscovered, ...lanDiscovered]
|
||||
|
||||
// Only update state if the list actually changed
|
||||
const prev = pt().getPipeIpc(store.getState())
|
||||
const prevNames = (prev.discoveredPipes ?? [])
|
||||
.map((p: any) => p.pipeName)
|
||||
.join(',')
|
||||
const newNames = allDiscovered.map(p => p.pipeName).join(',')
|
||||
if (prevNames === newNames) return
|
||||
|
||||
store.setState((prev: any) => {
|
||||
const pipeIpc = pt().getPipeIpc(prev)
|
||||
const aliveNames = new Set(allDiscovered.map(pipe => pipe.pipeName))
|
||||
return {
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...pipeIpc,
|
||||
discoveredPipes: allDiscovered,
|
||||
selectedPipes: (pipeIpc.selectedPipes ?? []).filter((name: string) =>
|
||||
aliveNames.has(name),
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase: Register message handlers on server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function registerMessageHandlers(
|
||||
server: PipeServer,
|
||||
pipeName: string,
|
||||
machineId: string,
|
||||
store: StoreApi,
|
||||
handleIncomingPrompt: (content: string) => boolean,
|
||||
): void {
|
||||
// Auto-reply pings for health checks
|
||||
server.onMessage((msg: PipeMessage, reply) => {
|
||||
if (msg.type === 'ping') reply({ type: 'pong' })
|
||||
})
|
||||
|
||||
// Handle attach requests
|
||||
server.onMessage((msg: PipeMessage, reply) => {
|
||||
if (msg.type !== 'attach_request') return
|
||||
const state = store.getState()
|
||||
const currentPipeState = pt().getPipeIpc(state)
|
||||
if (pt().isPipeControlled(currentPipeState)) {
|
||||
reply({ type: 'attach_reject', data: 'Already controlled' })
|
||||
return
|
||||
}
|
||||
// Allow LAN peers (different machineId) to attach regardless of role.
|
||||
const isLanPeer = msg.meta?.machineId && msg.meta.machineId !== machineId
|
||||
if (!isLanPeer && currentPipeState.role !== 'sub') {
|
||||
reply({
|
||||
type: 'attach_reject',
|
||||
data: 'Only sub sessions can be attached.',
|
||||
})
|
||||
return
|
||||
}
|
||||
reply({ type: 'attach_accept' })
|
||||
|
||||
const clients = Array.from((server as any).clients as Set<any>)
|
||||
const masterSocket = clients[clients.length - 1]
|
||||
pp().setPipeRelay((relayMsg: any) => {
|
||||
if (masterSocket && !masterSocket.destroyed) {
|
||||
relayMsg.from = relayMsg.from ?? pipeName
|
||||
relayMsg.ts = relayMsg.ts ?? new Date().toISOString()
|
||||
masterSocket.write(JSON.stringify(relayMsg) + '\n')
|
||||
}
|
||||
})
|
||||
|
||||
store.setState((prev: any) => ({
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...pt().getPipeIpc(prev),
|
||||
role: 'sub',
|
||||
displayRole: pt().getPipeDisplayRole(pt().getPipeIpc(prev)),
|
||||
attachedBy: msg.from ?? 'unknown',
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
// Handle prompts from master
|
||||
server.onMessage((msg: PipeMessage, reply) => {
|
||||
if (msg.type === 'prompt' && msg.data) {
|
||||
const accepted = handleIncomingPrompt(msg.data)
|
||||
if (accepted) {
|
||||
reply({ type: 'prompt_ack', data: 'accepted' })
|
||||
} else {
|
||||
reply({
|
||||
type: 'error',
|
||||
data: 'Slave is busy and could not accept the prompt.',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Handle permission decisions from master
|
||||
server.onMessage((msg: PipeMessage, _reply) => {
|
||||
if (msg.type !== 'permission_response' && msg.type !== 'permission_cancel')
|
||||
return
|
||||
const { resolvePipePermissionResponse, cancelPipePermissionRequest } =
|
||||
require('../utils/pipePermissionRelay.js') as typeof import('../utils/pipePermissionRelay.js')
|
||||
|
||||
try {
|
||||
const payload = msg.data ? JSON.parse(msg.data) : undefined
|
||||
if (!payload?.requestId) return
|
||||
if (msg.type === 'permission_response') {
|
||||
resolvePipePermissionResponse(payload)
|
||||
} else {
|
||||
cancelPipePermissionRequest(payload.requestId, payload.reason)
|
||||
}
|
||||
} catch {
|
||||
// Malformed — ignore
|
||||
}
|
||||
})
|
||||
|
||||
// Handle detach
|
||||
server.onMessage((msg: PipeMessage, _reply) => {
|
||||
if (msg.type !== 'detach') return
|
||||
const { clearPendingPipePermissions } =
|
||||
require('../utils/pipePermissionRelay.js') as typeof import('../utils/pipePermissionRelay.js')
|
||||
clearPendingPipePermissions('Pipe detached before permission was resolved.')
|
||||
pp().setPipeRelay(null)
|
||||
store.setState((prev: any) => ({
|
||||
...prev,
|
||||
pipeIpc: (() => {
|
||||
const pipeIpc = pt().getPipeIpc(prev)
|
||||
const nextRole = pipeIpc.subIndex != null ? 'sub' : 'main'
|
||||
const nextPipeState = { ...pipeIpc, role: nextRole, attachedBy: null }
|
||||
return {
|
||||
...nextPipeState,
|
||||
displayRole: pt().getPipeDisplayRole(nextPipeState as PipeIpcState),
|
||||
}
|
||||
})(),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase: Heartbeat
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runMainHeartbeat(
|
||||
pipeName: string,
|
||||
machineId: string,
|
||||
store: StoreApi,
|
||||
disposed: { current: boolean },
|
||||
): void {
|
||||
void (async () => {
|
||||
try {
|
||||
await pr().cleanupStaleEntries()
|
||||
const aliveSubs = await pr().getAliveSubs()
|
||||
refreshDiscoveredPipes(pipeName, aliveSubs, store)
|
||||
|
||||
const connectedSlaves = mm().getAllSlaveClients()
|
||||
const aliveSubNames = new Set(aliveSubs.map(sub => sub.pipeName))
|
||||
|
||||
// Build unified attach target list: local subs + LAN peers
|
||||
type AttachTarget = {
|
||||
pipeName: string
|
||||
tcpEndpoint?: { host: string; port: number }
|
||||
}
|
||||
const attachTargets: AttachTarget[] = aliveSubs.map(sub => ({
|
||||
pipeName: sub.pipeName,
|
||||
}))
|
||||
|
||||
// Add LAN peers as attach targets
|
||||
if (feature('LAN_PIPES')) {
|
||||
const beacon = lb().getLanBeacon()
|
||||
if (beacon) {
|
||||
const localNames = new Set(attachTargets.map(t => t.pipeName))
|
||||
localNames.add(pipeName)
|
||||
for (const [pName, peer] of beacon.getPeers()) {
|
||||
if (!localNames.has(pName)) {
|
||||
attachTargets.push({
|
||||
pipeName: pName,
|
||||
tcpEndpoint: { host: peer.ip, port: peer.tcpPort },
|
||||
})
|
||||
aliveSubNames.add(pName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentPipeState = pt().getPipeIpc(store.getState())
|
||||
|
||||
for (const target of attachTargets) {
|
||||
if (target.pipeName === pipeName) continue
|
||||
if (connectedSlaves.has(target.pipeName)) continue
|
||||
|
||||
try {
|
||||
const myName = currentPipeState.serverName ?? pipeName
|
||||
const client = await pt().connectToPipe(
|
||||
target.pipeName,
|
||||
myName,
|
||||
3000,
|
||||
target.tcpEndpoint,
|
||||
)
|
||||
|
||||
const attached = await new Promise<boolean>(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
client.disconnect()
|
||||
resolve(false)
|
||||
}, 3000)
|
||||
|
||||
client.onMessage((msg: any) => {
|
||||
if (msg.type === 'attach_accept') {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
} else if (msg.type === 'attach_reject') {
|
||||
clearTimeout(timeout)
|
||||
client.disconnect()
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
|
||||
client.send({
|
||||
type: 'attach_request',
|
||||
meta: { machineId },
|
||||
})
|
||||
})
|
||||
|
||||
if (attached && !disposed.current) {
|
||||
mm().addSlaveClient(target.pipeName, client)
|
||||
|
||||
client.on('disconnect', () => {
|
||||
removeDeadSlave(target.pipeName, store)
|
||||
})
|
||||
|
||||
store.setState((prev: any) => ({
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...pt().getPipeIpc(prev),
|
||||
role: 'master',
|
||||
displayRole: 'master',
|
||||
slaves: {
|
||||
...pt().getPipeIpc(prev).slaves,
|
||||
[target.pipeName]: {
|
||||
name: target.pipeName,
|
||||
connectedAt: new Date().toISOString(),
|
||||
status: 'idle',
|
||||
unreadCount: 0,
|
||||
history: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
// Connection failed — skip this cycle
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up slaves that are no longer alive
|
||||
let lanPeerNames: Set<string> | null = null
|
||||
if (feature('LAN_PIPES')) {
|
||||
const beacon = lb().getLanBeacon()
|
||||
if (beacon) {
|
||||
lanPeerNames = new Set(beacon.getPeers().keys())
|
||||
}
|
||||
}
|
||||
for (const [slaveName, client] of connectedSlaves.entries()) {
|
||||
const inLocalRegistry = aliveSubNames.has(slaveName)
|
||||
const inLanBeacon = lanPeerNames?.has(slaveName) ?? false
|
||||
if (!client.connected || (!inLocalRegistry && !inLanBeacon)) {
|
||||
removeDeadSlave(slaveName, store)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Heartbeat cycle error — non-fatal
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
function runSubHeartbeat(
|
||||
pipeName: string,
|
||||
machineId: string,
|
||||
entry: any,
|
||||
store: StoreApi,
|
||||
disposed: { current: boolean },
|
||||
): void {
|
||||
void (async () => {
|
||||
try {
|
||||
const mainAlive = await pr().isMainAlive()
|
||||
if (!mainAlive && !disposed.current) {
|
||||
const registry = await pr().readRegistry()
|
||||
const isSameMachine = pr().isMainMachine(machineId, registry)
|
||||
|
||||
if (isSameMachine) {
|
||||
await pr().registerAsMain(entry)
|
||||
} else {
|
||||
await pr().revertToIndependent(pipeName)
|
||||
}
|
||||
|
||||
store.setState((prev: any) => ({
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...pt().getPipeIpc(prev),
|
||||
role: 'main',
|
||||
subIndex: null,
|
||||
displayRole: 'main',
|
||||
attachedBy: null,
|
||||
},
|
||||
}))
|
||||
pp().setPipeRelay(null)
|
||||
}
|
||||
} catch {
|
||||
// Heartbeat check error — non-fatal
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function usePipeIpc({
|
||||
store,
|
||||
handleIncomingPrompt,
|
||||
}: UsePipeIpcOptions): void {
|
||||
if (!feature('UDS_INBOX')) return
|
||||
|
||||
useEffect(() => {
|
||||
const pipeName = `cli-${bs().getSessionId().slice(0, 8)}`
|
||||
const disposed = { current: false }
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
let heartbeatBusy = false
|
||||
let pipeServer: PipeServer | null = null
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
// --- Phase 1: Role determination ---
|
||||
const machId = await pr().getMachineId()
|
||||
const mac = pr().getMacAddress()
|
||||
const localIp = pt().getLocalIp()
|
||||
const host = osm().hostname()
|
||||
const roleResult = await pr().determineRole(machId)
|
||||
|
||||
const entry = {
|
||||
id: pipeName,
|
||||
pid: process.pid,
|
||||
machineId: machId,
|
||||
startedAt: Date.now(),
|
||||
ip: localIp,
|
||||
mac,
|
||||
hostname: host,
|
||||
pipeName,
|
||||
}
|
||||
|
||||
let initialRole: 'main' | 'sub' = 'main'
|
||||
let subIndex: number | null = null
|
||||
let displayRole = 'main'
|
||||
|
||||
if (roleResult.role === 'main' || roleResult.role === 'main-recover') {
|
||||
await pr().registerAsMain(entry)
|
||||
} else {
|
||||
subIndex = roleResult.subIndex
|
||||
await pr().registerAsSub(entry, subIndex)
|
||||
initialRole = 'sub'
|
||||
displayRole = `sub-${subIndex}`
|
||||
}
|
||||
|
||||
// --- Phase 2: Server creation ---
|
||||
const server = await pt().createPipeServer(
|
||||
pipeName,
|
||||
feature('LAN_PIPES') ? { enableTcp: true, tcpPort: 0 } : undefined,
|
||||
)
|
||||
pipeServer = server
|
||||
if (disposed.current) {
|
||||
await server.close()
|
||||
await pr().unregister(pipeName)
|
||||
return
|
||||
}
|
||||
|
||||
// --- Phase 3: LAN beacon ---
|
||||
if (feature('LAN_PIPES') && server.tcpAddress) {
|
||||
const beacon = new (lb().LanBeacon)({
|
||||
pipeName,
|
||||
machineId: machId,
|
||||
hostname: host,
|
||||
ip: localIp,
|
||||
tcpPort: server.tcpAddress.port,
|
||||
role: initialRole,
|
||||
})
|
||||
beacon.start()
|
||||
lb().setLanBeacon(beacon)
|
||||
|
||||
const entryWithTcp = {
|
||||
...entry,
|
||||
tcpPort: server.tcpAddress.port,
|
||||
lanVisible: true,
|
||||
}
|
||||
if (initialRole === 'main') {
|
||||
await pr().registerAsMain(entryWithTcp)
|
||||
} else if (subIndex != null) {
|
||||
await pr().registerAsSub(entryWithTcp, subIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Update store
|
||||
store.setState((prev: any) => ({
|
||||
...prev,
|
||||
pipeIpc: {
|
||||
...pt().getPipeIpc(prev),
|
||||
serverName: pipeName,
|
||||
role: initialRole,
|
||||
subIndex,
|
||||
displayRole,
|
||||
localIp,
|
||||
hostname: host,
|
||||
machineId: machId,
|
||||
mac,
|
||||
},
|
||||
}))
|
||||
|
||||
// --- Phase 4: Message handlers ---
|
||||
registerMessageHandlers(
|
||||
server,
|
||||
pipeName,
|
||||
machId,
|
||||
store,
|
||||
handleIncomingPrompt,
|
||||
)
|
||||
|
||||
// --- Phase 5: Heartbeat ---
|
||||
const HEARTBEAT_INTERVAL_MS = 5000
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (disposed.current || heartbeatBusy) return
|
||||
heartbeatBusy = true
|
||||
|
||||
const currentPipeState = pt().getPipeIpc(store.getState())
|
||||
|
||||
if (
|
||||
currentPipeState.role === 'main' ||
|
||||
currentPipeState.role === 'master'
|
||||
) {
|
||||
runMainHeartbeat(pipeName, machId, store, disposed)
|
||||
} else if (currentPipeState.role === 'sub') {
|
||||
runSubHeartbeat(pipeName, machId, entry, store, disposed)
|
||||
}
|
||||
|
||||
// Reset busy flag after a short delay to allow the async work to settle
|
||||
setTimeout(() => {
|
||||
heartbeatBusy = false
|
||||
}, 4000)
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
} catch {
|
||||
// PipeServer creation failed — non-fatal
|
||||
}
|
||||
})()
|
||||
|
||||
// --- Phase 6: Cleanup ---
|
||||
return () => {
|
||||
disposed.current = true
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
|
||||
// Send detach to all slaves
|
||||
const allClients = mm().getAllSlaveClients()
|
||||
for (const [name, client] of allClients.entries()) {
|
||||
try {
|
||||
client.send({ type: 'detach' })
|
||||
} catch {}
|
||||
client.disconnect()
|
||||
removeDeadSlave(name, store)
|
||||
}
|
||||
|
||||
// Stop LAN beacon
|
||||
const beacon = lb().getLanBeacon()
|
||||
if (beacon) {
|
||||
try {
|
||||
beacon.stop()
|
||||
} catch {}
|
||||
lb().setLanBeacon(null)
|
||||
}
|
||||
|
||||
// Unregister + close server
|
||||
void pr()
|
||||
.unregister(pipeName)
|
||||
.catch(() => {})
|
||||
if (pipeServer) {
|
||||
void pipeServer.close().catch(() => {})
|
||||
pipeServer = null
|
||||
}
|
||||
pp().setPipeRelay(null)
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
195
src/hooks/usePipePermissionForward.ts
Normal file
195
src/hooks/usePipePermissionForward.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* usePipePermissionForward — Forward slave permission requests to master UI.
|
||||
*
|
||||
* Subscribes to slave pipe messages via subscribePipeEntries, and:
|
||||
* 1. permission_request → enqueue into toolUseConfirmQueue for master approval
|
||||
* 2. permission_cancel → remove from queue
|
||||
* 3. stream/error/done → display as system messages
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import { useEffect } from 'react'
|
||||
import type { Tool, ToolUseContext } from '../Tool.js'
|
||||
import type { MessageType } from '../types/message.js'
|
||||
|
||||
type Deps = {
|
||||
store: { getState: () => any }
|
||||
tools: Tool<any, any>[]
|
||||
setMessages: (action: React.SetStateAction<MessageType[]>) => void
|
||||
setToolUseConfirmQueue: (action: React.SetStateAction<any[]>) => void
|
||||
getToolUseContext: (...args: any[]) => ToolUseContext
|
||||
mainLoopModel: string
|
||||
}
|
||||
|
||||
export function usePipePermissionForward({
|
||||
store,
|
||||
tools,
|
||||
setMessages,
|
||||
setToolUseConfirmQueue,
|
||||
getToolUseContext,
|
||||
mainLoopModel,
|
||||
}: Deps): void {
|
||||
useEffect(() => {
|
||||
if (!feature('UDS_INBOX')) return
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { subscribePipeEntries, getSlaveClient } =
|
||||
require('./useMasterMonitor.js') as typeof import('./useMasterMonitor.js')
|
||||
const { getPipeIpc } =
|
||||
require('../utils/pipeTransport.js') as typeof import('../utils/pipeTransport.js')
|
||||
const { createAssistantMessage, createSystemMessage } =
|
||||
require('../utils/messages.js') as typeof import('../utils/messages.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
return subscribePipeEntries(
|
||||
(pipeName: string, entry: { type: string; content: string }) => {
|
||||
const content = entry.content.trim()
|
||||
const pipeIpcState = getPipeIpc(store.getState())
|
||||
const peerInfo = (pipeIpcState.discoveredPipes ?? []).find(
|
||||
(pipe: { pipeName: string }) => pipe.pipeName === pipeName,
|
||||
)
|
||||
const isLan = peerInfo?.ip && peerInfo.ip !== pipeIpcState.localIp
|
||||
const displayRole = peerInfo
|
||||
? isLan
|
||||
? `${peerInfo.role} ${peerInfo.hostname}/${peerInfo.ip}`
|
||||
: peerInfo.role
|
||||
: pipeName
|
||||
|
||||
if (entry.type === 'permission_request') {
|
||||
try {
|
||||
const payload = JSON.parse(content)
|
||||
const tool = tools.find(
|
||||
candidate => candidate.name === payload.toolName,
|
||||
)
|
||||
const client = getSlaveClient(pipeName)
|
||||
if (!client) return
|
||||
|
||||
if (!tool) {
|
||||
client.send({
|
||||
type: 'permission_response',
|
||||
data: JSON.stringify({
|
||||
requestId: payload.requestId,
|
||||
behavior: 'deny',
|
||||
feedback: `Tool "${payload.toolName}" is not available in main.`,
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const assistantMessage = createAssistantMessage({ content: '' })
|
||||
const toolUseContext = getToolUseContext(
|
||||
[],
|
||||
[],
|
||||
new AbortController(),
|
||||
mainLoopModel,
|
||||
)
|
||||
setToolUseConfirmQueue((queue: any[]) => [
|
||||
...queue,
|
||||
{
|
||||
assistantMessage,
|
||||
tool,
|
||||
description: payload.description,
|
||||
input: payload.input,
|
||||
toolUseContext,
|
||||
toolUseID: `pipe:${payload.requestId}`,
|
||||
permissionResult: payload.permissionResult,
|
||||
permissionPromptStartTimeMs:
|
||||
payload.permissionPromptStartTimeMs,
|
||||
workerBadge: {
|
||||
name: `${displayRole} / ${pipeName}`,
|
||||
color: 'cyan',
|
||||
},
|
||||
onUserInteraction() {},
|
||||
onAbort() {
|
||||
client.send({
|
||||
type: 'permission_response',
|
||||
data: JSON.stringify({
|
||||
requestId: payload.requestId,
|
||||
behavior: 'deny',
|
||||
feedback: 'Permission request was aborted in main.',
|
||||
}),
|
||||
})
|
||||
},
|
||||
onAllow(
|
||||
updatedInput: any,
|
||||
permissionUpdates: any,
|
||||
feedback: any,
|
||||
contentBlocks: any,
|
||||
) {
|
||||
client.send({
|
||||
type: 'permission_response',
|
||||
data: JSON.stringify({
|
||||
requestId: payload.requestId,
|
||||
behavior: 'allow',
|
||||
updatedInput,
|
||||
permissionUpdates,
|
||||
feedback,
|
||||
contentBlocks,
|
||||
}),
|
||||
})
|
||||
},
|
||||
onReject(feedback: any, contentBlocks: any) {
|
||||
client.send({
|
||||
type: 'permission_response',
|
||||
data: JSON.stringify({
|
||||
requestId: payload.requestId,
|
||||
behavior: 'deny',
|
||||
feedback,
|
||||
contentBlocks,
|
||||
}),
|
||||
})
|
||||
},
|
||||
async recheckPermission() {},
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
// Malformed permission request — ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (entry.type === 'permission_cancel') {
|
||||
try {
|
||||
const payload = JSON.parse(content)
|
||||
setToolUseConfirmQueue((queue: any[]) =>
|
||||
queue.filter(
|
||||
(item: any) => item.toolUseID !== `pipe:${payload.requestId}`,
|
||||
),
|
||||
)
|
||||
} catch {
|
||||
// Malformed — ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let message: any = null
|
||||
|
||||
if (entry.type === 'stream' && content) {
|
||||
message = createSystemMessage(
|
||||
`[${displayRole} / ${pipeName}] ${content}`,
|
||||
'warning',
|
||||
)
|
||||
} else if (entry.type === 'error') {
|
||||
message = createSystemMessage(
|
||||
`[${displayRole} / ${pipeName}] Error: ${content || 'no output'}`,
|
||||
'error',
|
||||
)
|
||||
} else if (entry.type === 'done') {
|
||||
message = createSystemMessage(
|
||||
`[${displayRole} / ${pipeName}] Completed`,
|
||||
'warning',
|
||||
)
|
||||
}
|
||||
|
||||
if (message) {
|
||||
setMessages((prev: MessageType[]) => [...prev, message])
|
||||
}
|
||||
},
|
||||
)
|
||||
}, [
|
||||
getToolUseContext,
|
||||
mainLoopModel,
|
||||
setMessages,
|
||||
setToolUseConfirmQueue,
|
||||
store,
|
||||
tools,
|
||||
])
|
||||
}
|
||||
39
src/hooks/usePipeRelay.ts
Normal file
39
src/hooks/usePipeRelay.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* usePipeRelay — Pipe message relay utilities for slave → master communication.
|
||||
*
|
||||
* Provides `relayPipeMessage` and `pipeReturnHadErrorRef` for use in
|
||||
* onQuery callbacks. The relay function reads from the module-level
|
||||
* `getPipeRelay()` singleton set by usePipeIpc's attach handler.
|
||||
*/
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { getPipeRelay } from '../utils/pipePermissionRelay.js'
|
||||
import type { PipeMessage } from '../utils/pipeTransport.js'
|
||||
|
||||
export type PipeRelayHandle = {
|
||||
/** Send a relay message to the master. Returns false if no relay is active. */
|
||||
relayPipeMessage: (message: PipeMessage) => boolean
|
||||
/** Tracks whether an error was already relayed for this query turn. */
|
||||
pipeReturnHadErrorRef: React.MutableRefObject<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides pipe relay utilities. Safe to call unconditionally —
|
||||
* when UDS_INBOX is off, the relay function is a no-op that returns false.
|
||||
*/
|
||||
export function usePipeRelay(): PipeRelayHandle {
|
||||
const pipeReturnHadErrorRef = useRef(false)
|
||||
|
||||
const relayPipeMessage = useCallback(
|
||||
(message: PipeMessage): boolean => {
|
||||
const relay = getPipeRelay()
|
||||
if (typeof relay !== 'function') {
|
||||
return false
|
||||
}
|
||||
relay(message)
|
||||
return true
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return { relayPipeMessage, pipeReturnHadErrorRef }
|
||||
}
|
||||
151
src/hooks/usePipeRouter.ts
Normal file
151
src/hooks/usePipeRouter.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* usePipeRouter — Route user input to selected pipe targets.
|
||||
*
|
||||
* Returns `routeToSelectedPipes(input)` which checks selectedPipes +
|
||||
* routeMode and sends the prompt to all connected slave targets.
|
||||
* Returns true if routed (caller should skip local execution).
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
type StoreApi = { getState: () => any }
|
||||
type SetAppState = (updater: (prev: any) => any) => void
|
||||
type AddNotification = (opts: {
|
||||
key: string
|
||||
text: string
|
||||
color: string
|
||||
priority: string
|
||||
timeoutMs: number
|
||||
}) => void
|
||||
|
||||
type Deps = {
|
||||
store: StoreApi
|
||||
setAppState: SetAppState
|
||||
addNotification: AddNotification
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to route user input to selected pipes.
|
||||
* Returns true if routed to at least one pipe (skip local execution).
|
||||
*/
|
||||
export function usePipeRouter({ store, setAppState, addNotification }: Deps): {
|
||||
routeToSelectedPipes: (input: string) => boolean
|
||||
} {
|
||||
const routeToSelectedPipes = useCallback(
|
||||
(input: string): boolean => {
|
||||
if (!feature('UDS_INBOX')) return false
|
||||
if (!input.trim() || input.trim().startsWith('/')) return false
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const pipeState = (store.getState() as any).pipeIpc
|
||||
const selectedPipes: string[] = pipeState?.selectedPipes ?? []
|
||||
const routeMode: 'selected' | 'local' = pipeState?.routeMode ?? 'selected'
|
||||
|
||||
if (selectedPipes.length === 0 || routeMode === 'local') return false
|
||||
|
||||
const { getConnectedSlaveTargets } =
|
||||
require('./useMasterMonitor.js') as typeof import('./useMasterMonitor.js')
|
||||
const { getPipeIpc } =
|
||||
require('../utils/pipeTransport.js') as typeof import('../utils/pipeTransport.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
const targets = getConnectedSlaveTargets(selectedPipes)
|
||||
const pipeIpcForDisplay = getPipeIpc(store.getState())
|
||||
const discovered: Array<{
|
||||
pipeName: string
|
||||
role: string
|
||||
ip: string
|
||||
hostname: string
|
||||
}> = pipeIpcForDisplay.discoveredPipes ?? []
|
||||
|
||||
const sentTargetNames: string[] = []
|
||||
const sentTargetLabels: string[] = []
|
||||
const failedTargetNames: string[] = []
|
||||
|
||||
for (const { name, client } of targets) {
|
||||
try {
|
||||
client.send({ type: 'prompt', data: input.trim() })
|
||||
sentTargetNames.push(name)
|
||||
// Build display label: [role] hostname/ip for LAN, [role] for local
|
||||
const info = discovered.find((d: any) => d.pipeName === name)
|
||||
if (info) {
|
||||
const isLan = info.ip && info.ip !== pipeIpcForDisplay.localIp
|
||||
sentTargetLabels.push(
|
||||
isLan
|
||||
? `[${info.role}] ${info.hostname}/${info.ip}`
|
||||
: `[${info.role}]`,
|
||||
)
|
||||
} else {
|
||||
sentTargetLabels.push(name)
|
||||
}
|
||||
} catch {
|
||||
failedTargetNames.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
if (sentTargetNames.length > 0) {
|
||||
const promptText = input.trim()
|
||||
const promptTimestamp = new Date().toISOString()
|
||||
setAppState((prev: any) => {
|
||||
const pipeIpc = getPipeIpc(prev)
|
||||
const nextSlaves = { ...pipeIpc.slaves }
|
||||
for (const name of sentTargetNames) {
|
||||
const slave = nextSlaves[name]
|
||||
if (!slave) continue
|
||||
nextSlaves[name] = {
|
||||
...slave,
|
||||
status: 'busy',
|
||||
lastActivityAt: promptTimestamp,
|
||||
lastSummary: `Queued: ${promptText}`,
|
||||
lastEventType: 'prompt',
|
||||
history: [
|
||||
...slave.history,
|
||||
{
|
||||
type: 'prompt',
|
||||
content: promptText,
|
||||
from: pipeIpc.serverName ?? 'master',
|
||||
timestamp: promptTimestamp,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
pipeIpc: { ...pipeIpc, slaves: nextSlaves },
|
||||
}
|
||||
})
|
||||
|
||||
addNotification({
|
||||
key: 'pipe-route-success',
|
||||
text: `Routed to ${sentTargetLabels.join(', ')}; main can continue other tasks`,
|
||||
color: 'success',
|
||||
priority: 'immediate',
|
||||
timeoutMs: 3000,
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
key: 'pipe-route-fallback',
|
||||
text: 'Selected pipes are unavailable; processing locally.',
|
||||
color: 'warning',
|
||||
priority: 'immediate',
|
||||
timeoutMs: 4000,
|
||||
})
|
||||
}
|
||||
|
||||
if (failedTargetNames.length > 0) {
|
||||
addNotification({
|
||||
key: 'pipe-route-partial-failure',
|
||||
text: `Failed to send to: ${failedTargetNames.join(', ')}`,
|
||||
color: 'warning',
|
||||
priority: 'immediate',
|
||||
timeoutMs: 4000,
|
||||
})
|
||||
}
|
||||
|
||||
return sentTargetNames.length > 0
|
||||
},
|
||||
[store, setAppState, addNotification],
|
||||
)
|
||||
|
||||
return { routeToSelectedPipes }
|
||||
}
|
||||
122
src/hooks/useSlaveNotifications.ts
Normal file
122
src/hooks/useSlaveNotifications.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* useSlaveNotifications — Real-time toast notifications for slave CLI events
|
||||
*
|
||||
* When role === 'master', watches slave session history for key events
|
||||
* and shows toast notifications in the master CLI footer.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNotifications } from '../context/notifications.js'
|
||||
import { useAppState } from '../state/AppState.js'
|
||||
import { getPipeIpc } from '../utils/pipeTransport.js'
|
||||
import type { SessionEntry } from './useMasterMonitor.js'
|
||||
import type { Notification } from '../context/notifications.js'
|
||||
|
||||
function foldSlaveNotif(
|
||||
acc: Notification,
|
||||
_incoming: Notification,
|
||||
): Notification {
|
||||
if (!('text' in acc)) return acc
|
||||
const match = acc.text.match(/\((\d+)\)$/)
|
||||
const count = match ? parseInt(match[1], 10) + 1 : 2
|
||||
const base = acc.text.replace(/\s*\(\d+\)$/, '')
|
||||
return {
|
||||
...acc,
|
||||
text: `${base} (${count})`,
|
||||
fold: foldSlaveNotif,
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s
|
||||
return s.slice(0, max) + '…'
|
||||
}
|
||||
|
||||
export function useSlaveNotifications(): void {
|
||||
const role = useAppState(s => getPipeIpc(s).role)
|
||||
const slaves = useAppState(s => getPipeIpc(s).slaves)
|
||||
const { addNotification } = useNotifications()
|
||||
const lastSeenRef = useRef<Record<string, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (role !== 'master') return
|
||||
|
||||
for (const [name, slave] of Object.entries(slaves)) {
|
||||
const lastSeen = lastSeenRef.current[name] ?? 0
|
||||
const newEntries = slave.history.slice(lastSeen)
|
||||
lastSeenRef.current[name] = slave.history.length
|
||||
|
||||
for (const entry of newEntries) {
|
||||
const notification = makeNotification(name, entry)
|
||||
if (notification) {
|
||||
addNotification(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of Object.keys(lastSeenRef.current)) {
|
||||
if (!(name in slaves)) {
|
||||
delete lastSeenRef.current[name]
|
||||
}
|
||||
}
|
||||
}, [addNotification, role, slaves])
|
||||
}
|
||||
|
||||
function makeNotification(
|
||||
slaveName: string,
|
||||
entry: SessionEntry,
|
||||
): Notification | null {
|
||||
const shortName =
|
||||
slaveName.length > 16 ? `${slaveName.slice(0, 16)}…` : slaveName
|
||||
|
||||
switch (entry.type) {
|
||||
case 'prompt_ack':
|
||||
return {
|
||||
key: `slave-ack-${slaveName}`,
|
||||
text: `[${shortName}] ✓ 已接收任务`,
|
||||
priority: 'low',
|
||||
timeoutMs: 2500,
|
||||
fold: foldSlaveNotif,
|
||||
}
|
||||
|
||||
case 'done':
|
||||
return {
|
||||
key: `slave-done-${slaveName}`,
|
||||
text: `[${shortName}] ✓ 任务完成`,
|
||||
priority: 'medium',
|
||||
timeoutMs: 5000,
|
||||
fold: foldSlaveNotif,
|
||||
}
|
||||
|
||||
case 'error':
|
||||
return {
|
||||
key: `slave-error-${slaveName}`,
|
||||
text: `[${shortName}] ✗ 错误: ${truncate(entry.content, 60)}`,
|
||||
color: 'error',
|
||||
priority: 'high',
|
||||
timeoutMs: 8000,
|
||||
}
|
||||
|
||||
case 'tool_start':
|
||||
return {
|
||||
key: `slave-tool-${slaveName}`,
|
||||
text: `[${shortName}] 工具: ${truncate(entry.content, 40)}`,
|
||||
priority: 'low',
|
||||
timeoutMs: 3000,
|
||||
fold: foldSlaveNotif,
|
||||
}
|
||||
|
||||
case 'prompt':
|
||||
return {
|
||||
key: `slave-prompt-${slaveName}`,
|
||||
text: `[${shortName}] ▶ 开始处理: ${truncate(entry.content, 50)}`,
|
||||
priority: 'medium',
|
||||
timeoutMs: 4000,
|
||||
}
|
||||
|
||||
case 'stream':
|
||||
case 'tool_result':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
17
src/main.tsx
17
src/main.tsx
@@ -1122,14 +1122,15 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for -p/--print and --init-only flags early to set isInteractiveSession before init()
|
||||
// This is needed because telemetry initialization calls auth functions that need this flag
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const hasPrintFlag = cliArgs.includes("-p") || cliArgs.includes("--print");
|
||||
const hasInitOnlyFlag = cliArgs.includes("--init-only");
|
||||
const hasSdkUrl = cliArgs.some((arg) => arg.startsWith("--sdk-url"));
|
||||
const isNonInteractive =
|
||||
hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY;
|
||||
// Check for -p/--print and --init-only flags early to set isInteractiveSession before init()
|
||||
// This is needed because telemetry initialization calls auth functions that need this flag
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const hasPrintFlag = cliArgs.includes("-p") || cliArgs.includes("--print");
|
||||
const hasInitOnlyFlag = cliArgs.includes("--init-only");
|
||||
const hasSdkUrl = cliArgs.some((arg) => arg.startsWith("--sdk-url"));
|
||||
const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE);
|
||||
const isNonInteractive =
|
||||
hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || (!forceInteractive && !process.stdout.isTTY);
|
||||
|
||||
// Stop capturing early input for non-interactive modes
|
||||
if (isNonInteractive) {
|
||||
|
||||
@@ -1,6 +1,135 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const isProactiveActive: () => boolean = () => false;
|
||||
export const activateProactive: (source?: string) => void = () => {};
|
||||
export const isProactivePaused: () => boolean = () => false;
|
||||
export const deactivateProactive: () => void = () => {};
|
||||
/**
|
||||
* Proactive mode — tick-driven autonomous agent.
|
||||
*
|
||||
* State machine: inactive → active (→ paused → active) → inactive
|
||||
*
|
||||
* When active, the REPL periodically injects <tick> prompts so the model
|
||||
* keeps working even when the user is idle. SleepTool lets the model
|
||||
* control its own wake-up cadence.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let active = false
|
||||
let paused = false
|
||||
let contextBlocked = false
|
||||
let nextTickAt: number | null = null
|
||||
let activationSource: string | undefined
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function notify(): void {
|
||||
for (const cb of listeners) {
|
||||
try {
|
||||
cb()
|
||||
} catch {
|
||||
// subscriber errors must not break the notifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API — consumed by REPL.tsx, PromptInputFooterLeftSide, prompts.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isProactiveActive(): boolean {
|
||||
return active
|
||||
}
|
||||
|
||||
export function activateProactive(source?: string): void {
|
||||
if (active) return
|
||||
active = true
|
||||
paused = false
|
||||
contextBlocked = false
|
||||
activationSource = source
|
||||
notify()
|
||||
}
|
||||
|
||||
export function deactivateProactive(): void {
|
||||
if (!active) return
|
||||
active = false
|
||||
paused = false
|
||||
contextBlocked = false
|
||||
nextTickAt = null
|
||||
activationSource = undefined
|
||||
notify()
|
||||
}
|
||||
|
||||
export function isProactivePaused(): boolean {
|
||||
return paused
|
||||
}
|
||||
|
||||
export function pauseProactive(): void {
|
||||
if (!active || paused) return
|
||||
paused = true
|
||||
nextTickAt = null
|
||||
notify()
|
||||
}
|
||||
|
||||
export function resumeProactive(): void {
|
||||
if (!active || !paused) return
|
||||
paused = false
|
||||
notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* Block / unblock tick generation.
|
||||
*
|
||||
* Set to `true` on API errors to prevent tick → error → tick runaway loops.
|
||||
* Cleared on successful response or after compaction.
|
||||
*/
|
||||
export function setContextBlocked(blocked: boolean): void {
|
||||
if (contextBlocked === blocked) return
|
||||
contextBlocked = blocked
|
||||
if (blocked) {
|
||||
nextTickAt = null
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
export function isContextBlocked(): boolean {
|
||||
return contextBlocked
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next tick timestamp (epoch ms).
|
||||
* Called by useProactive after submitting a tick.
|
||||
*/
|
||||
export function setNextTickAt(ts: number | null): void {
|
||||
nextTickAt = ts
|
||||
notify()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the epoch-ms timestamp of the next scheduled tick, or null.
|
||||
* Used by PromptInputFooterLeftSide to render a countdown.
|
||||
*/
|
||||
export function getNextTickAt(): number | null {
|
||||
if (!active || paused || contextBlocked) return null
|
||||
return nextTickAt
|
||||
}
|
||||
|
||||
export function getActivationSource(): string | undefined {
|
||||
return activationSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to any proactive state change.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export function subscribeToProactiveChanges(cb: () => void): () => void {
|
||||
listeners.add(cb)
|
||||
return () => {
|
||||
listeners.delete(cb)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether ticks should fire right now.
|
||||
* Convenience predicate combining all blocking conditions.
|
||||
*/
|
||||
export function shouldTick(): boolean {
|
||||
return active && !paused && !contextBlocked
|
||||
}
|
||||
|
||||
102
src/proactive/useProactive.ts
Normal file
102
src/proactive/useProactive.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* useProactive — React hook that drives tick generation for proactive mode.
|
||||
*
|
||||
* Mounted inside REPL.tsx when feature('PROACTIVE') || feature('KAIROS').
|
||||
* Generates <tick>HH:MM:SS</tick> prompts at a fixed interval while
|
||||
* proactive mode is active and not blocked.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { TICK_TAG } from '../constants/xml.js'
|
||||
import {
|
||||
isProactiveActive,
|
||||
isProactivePaused,
|
||||
isContextBlocked,
|
||||
setNextTickAt,
|
||||
shouldTick,
|
||||
} from './index.js'
|
||||
|
||||
/** Default interval between ticks (ms). Prompt cache TTL is ~5 min so we
|
||||
* stay well under that to keep the cache warm. */
|
||||
const TICK_INTERVAL_MS = 30_000
|
||||
|
||||
type UseProactiveOpts = {
|
||||
isLoading: boolean
|
||||
queuedCommandsLength: number
|
||||
hasActiveLocalJsxUI: boolean
|
||||
isInPlanMode: boolean
|
||||
onSubmitTick: (prompt: string) => void
|
||||
onQueueTick: (prompt: string) => void
|
||||
}
|
||||
|
||||
export function useProactive(opts: UseProactiveOpts): void {
|
||||
const optsRef = useRef(opts)
|
||||
optsRef.current = opts
|
||||
|
||||
useEffect(() => {
|
||||
if (!isProactiveActive()) return
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function scheduleTick(): void {
|
||||
const nextTs = Date.now() + TICK_INTERVAL_MS
|
||||
setNextTickAt(nextTs)
|
||||
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
|
||||
// Guard: skip tick if any blocking condition is met
|
||||
if (!shouldTick()) {
|
||||
// Reschedule — conditions may clear later
|
||||
scheduleTick()
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
queuedCommandsLength,
|
||||
hasActiveLocalJsxUI,
|
||||
isInPlanMode,
|
||||
} = optsRef.current
|
||||
|
||||
// Don't fire while a query is in-flight, plan mode is active,
|
||||
// a local JSX UI is showing, or commands are queued
|
||||
if (
|
||||
isLoading ||
|
||||
isInPlanMode ||
|
||||
hasActiveLocalJsxUI ||
|
||||
queuedCommandsLength > 0
|
||||
) {
|
||||
scheduleTick()
|
||||
return
|
||||
}
|
||||
|
||||
const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`
|
||||
|
||||
// If nothing is in the queue, submit directly; otherwise queue
|
||||
if (queuedCommandsLength === 0) {
|
||||
optsRef.current.onSubmitTick(tickContent)
|
||||
} else {
|
||||
optsRef.current.onQueueTick(tickContent)
|
||||
}
|
||||
|
||||
// Schedule next tick
|
||||
scheduleTick()
|
||||
}, TICK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
scheduleTick()
|
||||
|
||||
return () => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
setNextTickAt(null)
|
||||
}
|
||||
}, [
|
||||
// Re-mount when proactive state changes
|
||||
isProactiveActive(),
|
||||
isProactivePaused(),
|
||||
isContextBlocked(),
|
||||
])
|
||||
}
|
||||
@@ -258,6 +258,7 @@ import { useManagePlugins } from '../hooks/useManagePlugins.js';
|
||||
import { Messages } from '../components/Messages.js';
|
||||
import { TaskListV2 } from '../components/TaskListV2.js';
|
||||
import { TeammateViewHeader } from '../components/TeammateViewHeader.js';
|
||||
import { getPipeDisplayRole, getPipeIpc, isPipeControlled } from '../utils/pipeTransport.js';
|
||||
import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js';
|
||||
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js';
|
||||
import type { MCPServerConnection } from '../services/mcp/types.js';
|
||||
@@ -332,6 +333,22 @@ const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
|
||||
const useProactive =
|
||||
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
|
||||
const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null;
|
||||
const useMasterMonitor = feature('UDS_INBOX')
|
||||
? require('../hooks/useMasterMonitor.js').useMasterMonitor
|
||||
: () => undefined;
|
||||
const useSlaveNotifications = feature('UDS_INBOX')
|
||||
? require('../hooks/useSlaveNotifications.js').useSlaveNotifications
|
||||
: () => undefined;
|
||||
const usePipeIpc = feature('UDS_INBOX') ? require('../hooks/usePipeIpc.js').usePipeIpc : () => undefined;
|
||||
const usePipeRelay = feature('UDS_INBOX')
|
||||
? require('../hooks/usePipeRelay.js').usePipeRelay
|
||||
: () => ({ relayPipeMessage: () => false, pipeReturnHadErrorRef: { current: false } });
|
||||
const usePipePermissionForward = feature('UDS_INBOX')
|
||||
? require('../hooks/usePipePermissionForward.js').usePipePermissionForward
|
||||
: () => undefined;
|
||||
const usePipeRouter = feature('UDS_INBOX')
|
||||
? require('../hooks/usePipeRouter.js').usePipeRouter
|
||||
: () => ({ routeToSelectedPipes: () => false });
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
|
||||
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js';
|
||||
@@ -823,8 +840,7 @@ export function REPL({
|
||||
);
|
||||
const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
|
||||
const disableMessageActions = feature('MESSAGE_ACTIONS')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), [])
|
||||
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), [])
|
||||
: false;
|
||||
|
||||
// Log REPL mount/unmount lifecycle
|
||||
@@ -1478,7 +1494,6 @@ export function REPL({
|
||||
messages.length,
|
||||
);
|
||||
if (feature('AWAY_SUMMARY')) {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useAwaySummary(messages, setMessages, isLoading);
|
||||
}
|
||||
const [cursor, setCursor] = useState<MessageActionsState | null>(null);
|
||||
@@ -1515,8 +1530,7 @@ export function REPL({
|
||||
// the branch is dead-code-eliminated in non-KAIROS builds (same pattern
|
||||
// as useUnseenDivider above).
|
||||
const { maybeLoadOlder } = feature('KAIROS')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useAssistantHistory({
|
||||
? useAssistantHistory({
|
||||
config: remoteSessionConfig,
|
||||
setMessages,
|
||||
scrollRef,
|
||||
@@ -3091,6 +3105,34 @@ export function REPL({
|
||||
proactiveModule?.setContextBlocked(false);
|
||||
}
|
||||
}
|
||||
// Relay assistant response to master when in slave mode.
|
||||
if (feature('UDS_INBOX') && newMessage.type === 'assistant') {
|
||||
// Extract text from content blocks (API format)
|
||||
const msg = newMessage.message as any;
|
||||
const contentBlocks = msg?.content ?? (newMessage as any).content ?? [];
|
||||
const textParts: string[] = [];
|
||||
if (Array.isArray(contentBlocks)) {
|
||||
for (const block of contentBlocks) {
|
||||
if (typeof block === 'string') {
|
||||
textParts.push(block);
|
||||
} else if (block?.type === 'text' && block.text) {
|
||||
textParts.push(block.text);
|
||||
}
|
||||
}
|
||||
} else if (typeof contentBlocks === 'string') {
|
||||
textParts.push(contentBlocks);
|
||||
}
|
||||
const text = textParts.join('\n').trim();
|
||||
if ('isApiErrorMessage' in newMessage && newMessage.isApiErrorMessage) {
|
||||
pipeReturnHadErrorRef.current = true;
|
||||
relayPipeMessage({
|
||||
type: 'error',
|
||||
data: text || 'Slave request failed',
|
||||
});
|
||||
} else if (text) {
|
||||
relayPipeMessage({ type: 'stream', data: text });
|
||||
}
|
||||
}
|
||||
},
|
||||
newContent => {
|
||||
// setResponseLength handles updating both responseLengthRef (for
|
||||
@@ -3320,6 +3362,16 @@ export function REPL({
|
||||
|
||||
queryCheckpoint('query_end');
|
||||
|
||||
if (feature('UDS_INBOX')) {
|
||||
if (abortController.signal.aborted) {
|
||||
pipeReturnHadErrorRef.current = true;
|
||||
relayPipeMessage({
|
||||
type: 'error',
|
||||
data: 'Slave request was interrupted before completion.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Capture ant-only API metrics before resetLoadingState clears the ref.
|
||||
// For multi-request turns (tool use loops), compute P50 across all requests.
|
||||
if (process.env.USER_TYPE === 'ant' && apiMetricsRef.current.length > 0) {
|
||||
@@ -3431,6 +3483,7 @@ export function REPL({
|
||||
}
|
||||
|
||||
try {
|
||||
pipeReturnHadErrorRef.current = false;
|
||||
// isLoading is derived from queryGuard — tryStart() above already
|
||||
// transitioned dispatching→running, so no setter call needed here.
|
||||
resetTimingRefs();
|
||||
@@ -3463,15 +3516,26 @@ export function REPL({
|
||||
}
|
||||
}
|
||||
|
||||
await onQueryImpl(
|
||||
latestMessages,
|
||||
newMessages,
|
||||
abortController,
|
||||
shouldQuery,
|
||||
additionalAllowedTools,
|
||||
mainLoopModelParam,
|
||||
effort,
|
||||
);
|
||||
try {
|
||||
await onQueryImpl(
|
||||
latestMessages,
|
||||
newMessages,
|
||||
abortController,
|
||||
shouldQuery,
|
||||
additionalAllowedTools,
|
||||
mainLoopModelParam,
|
||||
effort,
|
||||
);
|
||||
} catch (error) {
|
||||
if (feature('UDS_INBOX')) {
|
||||
pipeReturnHadErrorRef.current = true;
|
||||
relayPipeMessage({
|
||||
type: 'error',
|
||||
data: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
// queryGuard.end() atomically checks generation and transitions
|
||||
// running→idle. Returns false if a newer query owns the guard
|
||||
@@ -3486,6 +3550,13 @@ export function REPL({
|
||||
|
||||
await mrOnTurnComplete(messagesRef.current, abortController.signal.aborted);
|
||||
|
||||
if (feature('UDS_INBOX') && !pipeReturnHadErrorRef.current) {
|
||||
relayPipeMessage({
|
||||
type: 'done',
|
||||
data: '',
|
||||
});
|
||||
}
|
||||
|
||||
// Notify bridge clients that the turn is complete so mobile apps
|
||||
// can stop the spark animation and show post-turn UI.
|
||||
sendBridgeResultRef.current();
|
||||
@@ -3747,6 +3818,27 @@ export function REPL({
|
||||
proactiveModule?.resumeProactive();
|
||||
}
|
||||
|
||||
// Route user input to selected pipe targets (extracted to usePipeRouter)
|
||||
if (routeToSelectedPipes(input)) {
|
||||
// Show the user's prompt in the message list so they can see what was sent
|
||||
const userMessage = createUserMessage({ content: input });
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
|
||||
if (!options?.fromKeybinding) {
|
||||
addToHistory({
|
||||
display: prependModeCharacterToInput(input, inputMode),
|
||||
pastedContents,
|
||||
});
|
||||
}
|
||||
setInputValue('');
|
||||
helpers.setCursorOffset(0);
|
||||
helpers.clearBuffer();
|
||||
setPastedContents({});
|
||||
setInputMode('prompt');
|
||||
setIDESelection(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle immediate commands - these bypass the queue and execute right away
|
||||
// even while Claude is processing. Commands opt-in via `immediate: true`.
|
||||
// Commands triggered via keybindings are always treated as immediate.
|
||||
@@ -4739,10 +4831,11 @@ export function REPL({
|
||||
[onQuery, mainLoopModel, store],
|
||||
);
|
||||
|
||||
const { relayPipeMessage, pipeReturnHadErrorRef } = usePipeRelay();
|
||||
|
||||
// Voice input integration (VOICE_MODE builds only)
|
||||
const voice = feature('VOICE_MODE')
|
||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef })
|
||||
? useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef })
|
||||
: {
|
||||
stripTrailing: () => 0,
|
||||
handleKeyEvent: () => {},
|
||||
@@ -4758,6 +4851,15 @@ export function REPL({
|
||||
});
|
||||
|
||||
useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt });
|
||||
useMasterMonitor();
|
||||
useSlaveNotifications();
|
||||
const pipeIpcState = useAppState(s => getPipeIpc(s as any));
|
||||
|
||||
usePipePermissionForward({ store, tools, setMessages, setToolUseConfirmQueue, getToolUseContext, mainLoopModel });
|
||||
|
||||
// Pipe IPC lifecycle — extracted to usePipeIpc hook
|
||||
usePipeIpc({ store, handleIncomingPrompt });
|
||||
const { routeToSelectedPipes } = usePipeRouter({ store, setAppState, addNotification });
|
||||
|
||||
// Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
|
||||
if (feature('AGENT_TRIGGERS')) {
|
||||
@@ -4768,7 +4870,6 @@ export function REPL({
|
||||
// useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic
|
||||
// condition would break rules-of-hooks.
|
||||
const assistantMode = store.getState().kairosEnabled;
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
||||
useScheduledTasks!({ isLoading, assistantMode, setMessages });
|
||||
}
|
||||
|
||||
@@ -4779,29 +4880,28 @@ export function REPL({
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
// Tasks mode: watch for tasks and auto-process them
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
|
||||
useTaskListWatcher({
|
||||
taskListId,
|
||||
isLoading,
|
||||
onSubmitTask: handleIncomingPrompt,
|
||||
});
|
||||
|
||||
// Loop mode: auto-tick when enabled (via /job command)
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
|
||||
useProactive?.({
|
||||
// Suppress ticks while an initial message is pending — the initial
|
||||
// message will be processed asynchronously and a premature tick would
|
||||
// race with it, causing concurrent-query enqueue of expanded skill text.
|
||||
isLoading: isLoading || initialMessage !== null,
|
||||
queuedCommandsLength: queuedCommands.length,
|
||||
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
|
||||
isInPlanMode: toolPermissionContext.mode === 'plan',
|
||||
onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { isMeta: true }),
|
||||
onQueueTick: (prompt: string) => enqueue({ mode: 'prompt', value: prompt, isMeta: true }),
|
||||
});
|
||||
}
|
||||
|
||||
// Proactive mode: auto-tick when enabled (via /proactive command)
|
||||
// Moved out of USER_TYPE === 'ant' block so external users can use it.
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useProactive?.({
|
||||
// Suppress ticks while an initial message is pending — the initial
|
||||
// message will be processed asynchronously and a premature tick would
|
||||
// race with it, causing concurrent-query enqueue of expanded skill text.
|
||||
isLoading: isLoading || initialMessage !== null,
|
||||
queuedCommandsLength: queuedCommands.length,
|
||||
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
|
||||
isInPlanMode: toolPermissionContext.mode === 'plan',
|
||||
onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { isMeta: true }),
|
||||
onQueueTick: (prompt: string) => enqueue({ mode: 'prompt', value: prompt, isMeta: true }),
|
||||
});
|
||||
|
||||
// Abort the current operation when a 'now' priority message arrives
|
||||
// (e.g. from a chat UI client via UDS).
|
||||
useEffect(() => {
|
||||
@@ -5119,8 +5219,15 @@ export function REPL({
|
||||
// Handle shift+down for teammate navigation and background task management.
|
||||
// Guard onOpenBackgroundTasks when a local-jsx dialog (e.g. /mcp) is open —
|
||||
// otherwise Shift+Down stacks BackgroundTasksDialog on top and deadlocks input.
|
||||
// Third case: Shift+Down toggles the pipe IPC selector panel when pipes are active.
|
||||
useBackgroundTaskNavigation({
|
||||
onOpenBackgroundTasks: isShowingLocalJSXCommand ? undefined : () => setShowBashesDialog(true),
|
||||
onTogglePipeSelector: () => {
|
||||
setAppState((prev: any) => {
|
||||
const pIpc = prev.pipeIpc ?? {};
|
||||
return { ...prev, pipeIpc: { ...pIpc, selectorOpen: !pIpc.selectorOpen } };
|
||||
});
|
||||
},
|
||||
});
|
||||
// Auto-exit viewing mode when teammate completes or errors
|
||||
useTeammateViewAutoExit();
|
||||
@@ -5375,12 +5482,12 @@ export function REPL({
|
||||
// /config, /theme, /diff, ...) both go here now.
|
||||
const toolJsxCentered = isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true;
|
||||
const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null;
|
||||
|
||||
// <AlternateScreen> at the root: everything below is inside its
|
||||
// <Box height={rows}>. Handlers/contexts are zero-height so ScrollBox's
|
||||
// flexGrow in FullscreenLayout resolves against this Box. The transcript
|
||||
// early return above wraps its virtual-scroll branch the same way; only
|
||||
// the 30-cap dump branch stays unwrapped for native terminal scrollback.
|
||||
|
||||
const mainReturn = (
|
||||
<KeybindingSetup>
|
||||
<AnimatedTerminalTitle
|
||||
@@ -5413,7 +5520,7 @@ export function REPL({
|
||||
isFullscreenEnvEnabled() &&
|
||||
(centeredModal != null || !focusedInputDialog || focusedInputDialog === 'tool-permission')
|
||||
}
|
||||
onScroll={centeredModal || toolPermissionOverlay || viewedAgentTask ? undefined : composedOnScroll}
|
||||
onScroll={composedOnScroll}
|
||||
/>
|
||||
{feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? (
|
||||
<MessageActionsKeybindings handlers={messageActionHandlers} isActive={cursor !== null} />
|
||||
|
||||
@@ -1,7 +1,142 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
import type { Command } from 'src/types/command.js';
|
||||
export const fetchMcpSkillsForClient: ((...args: unknown[]) => Promise<Command[]>) & { cache: Map<string, unknown> } = Object.assign(
|
||||
(..._args: unknown[]) => Promise.resolve([] as Command[]),
|
||||
{ cache: new Map<string, unknown>() }
|
||||
);
|
||||
import {
|
||||
type ListResourcesResult,
|
||||
ListResourcesResultSchema,
|
||||
type ReadResourceResult,
|
||||
ReadResourceResultSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { Command } from '../commands.js'
|
||||
import type { MCPServerConnection } from '../services/mcp/types.js'
|
||||
import { normalizeNameForMCP } from '../services/mcp/normalization.js'
|
||||
import { memoizeWithLRU } from '../utils/memoize.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { logMCPDebug, logMCPError } from '../utils/log.js'
|
||||
import { recursivelySanitizeUnicode } from '../utils/sanitization.js'
|
||||
import { parseFrontmatter } from '../utils/frontmatterParser.js'
|
||||
import { getMCPSkillBuilders } from './mcpSkillBuilders.js'
|
||||
|
||||
const SKILL_URI_PREFIX = 'skill://'
|
||||
const MCP_FETCH_CACHE_SIZE = 20
|
||||
|
||||
/**
|
||||
* Discovers skills exposed as `skill://` resources by an MCP server.
|
||||
*
|
||||
* Each matching resource is read, its markdown content is parsed for
|
||||
* frontmatter, and the result is converted into a Command that the skill
|
||||
* system can index and invoke just like a local `.md` skill file.
|
||||
*
|
||||
* Memoized by server name so repeated calls within a connection lifecycle
|
||||
* return the cached result. Callers invalidate via `.cache.delete(name)`.
|
||||
*/
|
||||
export const fetchMcpSkillsForClient = memoizeWithLRU(
|
||||
async (client: MCPServerConnection): Promise<Command[]> => {
|
||||
if (client.type !== 'connected') return []
|
||||
|
||||
try {
|
||||
if (!client.capabilities?.resources) {
|
||||
return []
|
||||
}
|
||||
|
||||
// List all resources and filter to skill:// URIs
|
||||
const result = (await client.client.request(
|
||||
{ method: 'resources/list' },
|
||||
ListResourcesResultSchema,
|
||||
)) as ListResourcesResult
|
||||
|
||||
if (!result.resources) return []
|
||||
|
||||
const skillResources = result.resources.filter(r =>
|
||||
r.uri.startsWith(SKILL_URI_PREFIX),
|
||||
)
|
||||
|
||||
if (skillResources.length === 0) return []
|
||||
|
||||
logMCPDebug(
|
||||
client.name,
|
||||
`Found ${skillResources.length} skill resource(s)`,
|
||||
)
|
||||
|
||||
const { createSkillCommand, parseSkillFrontmatterFields } =
|
||||
getMCPSkillBuilders()
|
||||
|
||||
const commands: Command[] = []
|
||||
|
||||
for (const resource of skillResources) {
|
||||
try {
|
||||
// Read the skill resource content
|
||||
const readResult = (await client.client.request(
|
||||
{
|
||||
method: 'resources/read',
|
||||
params: { uri: resource.uri },
|
||||
},
|
||||
ReadResourceResultSchema,
|
||||
)) as ReadResourceResult
|
||||
|
||||
// Extract text content from the resource
|
||||
const textContent = readResult.contents
|
||||
?.map(c => ('text' in c ? c.text : undefined))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
if (!textContent) {
|
||||
logMCPDebug(
|
||||
client.name,
|
||||
`Skill resource ${resource.uri} returned no text content, skipping`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const sanitizedContent = recursivelySanitizeUnicode(textContent)
|
||||
|
||||
// Parse the markdown frontmatter
|
||||
const { frontmatter, content: markdownContent } =
|
||||
parseFrontmatter(sanitizedContent)
|
||||
|
||||
// Derive a skill name from the resource URI. Strip the skill://
|
||||
// prefix and use the remainder, prefixed with the MCP server name
|
||||
// so it is unique across servers.
|
||||
const rawName = resource.uri.slice(SKILL_URI_PREFIX.length)
|
||||
const skillName =
|
||||
'mcp__' + normalizeNameForMCP(client.name) + '__' + rawName
|
||||
|
||||
const parsed = parseSkillFrontmatterFields(
|
||||
frontmatter,
|
||||
markdownContent,
|
||||
skillName,
|
||||
)
|
||||
|
||||
commands.push(
|
||||
createSkillCommand({
|
||||
...parsed,
|
||||
skillName,
|
||||
markdownContent,
|
||||
source: 'mcp',
|
||||
loadedFrom: 'mcp',
|
||||
baseDir: undefined,
|
||||
paths: undefined,
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
logMCPError(
|
||||
client.name,
|
||||
`Failed to load skill resource ${resource.uri}: ${errorMessage(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logMCPDebug(
|
||||
client.name,
|
||||
`Loaded ${commands.length} skill(s) from resources`,
|
||||
)
|
||||
|
||||
return commands
|
||||
} catch (error) {
|
||||
logMCPError(
|
||||
client.name,
|
||||
`Failed to fetch skill resources: ${errorMessage(error)}`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
},
|
||||
(client: MCPServerConnection) => client.name,
|
||||
MCP_FETCH_CACHE_SIZE,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,207 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { TaskStateBase, SetAppState } from '../../Task.js'
|
||||
// Background task entry for local workflow execution.
|
||||
// Makes workflow scripts visible in the footer pill and Shift+Down
|
||||
// dialog. Follows the DreamTask pattern: lifecycle + UI surfacing via
|
||||
// the existing task registry.
|
||||
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import type { SetAppState, Task, TaskStateBase } from '../../Task.js'
|
||||
import { createTaskStateBase, generateTaskId } from '../../Task.js'
|
||||
import type { AgentId } from '../../types/ids.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { registerTask, updateTaskState } from '../../utils/task/framework.js'
|
||||
|
||||
export type LocalWorkflowTaskState = TaskStateBase & {
|
||||
type: 'local_workflow'
|
||||
/** meta.name from the workflow script (e.g. 'spec'). */
|
||||
workflowName: string
|
||||
/** Absolute path to the workflow file on disk. */
|
||||
workflowFile: string
|
||||
/** Human-readable one-line summary for the task list. */
|
||||
summary?: string
|
||||
description: string
|
||||
/** Number of sub-agents spawned by this workflow. */
|
||||
agentCount?: number
|
||||
/** Captured output from workflow execution. */
|
||||
output?: string
|
||||
/** Agent that spawned this task. Used for orphan cleanup. */
|
||||
agentId?: AgentId
|
||||
/** Abort controller for cancellation. */
|
||||
abortController?: AbortController
|
||||
/**
|
||||
* Pending action for a sub-agent within this workflow.
|
||||
* The workflow execution loop polls this field and acts on it.
|
||||
*/
|
||||
pendingAgentAction?: {
|
||||
kind: 'skip' | 'retry'
|
||||
agentId: AgentId
|
||||
requestedAt: number
|
||||
}
|
||||
}
|
||||
|
||||
export function isLocalWorkflowTask(
|
||||
value: unknown,
|
||||
): value is LocalWorkflowTaskState {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'type' in value &&
|
||||
(value as { type: string }).type === 'local_workflow'
|
||||
)
|
||||
}
|
||||
|
||||
export function registerLocalWorkflowTask(
|
||||
setAppState: SetAppState,
|
||||
opts: {
|
||||
description: string
|
||||
workflowName: string
|
||||
workflowFile: string
|
||||
summary?: string
|
||||
toolUseId?: string
|
||||
agentId?: AgentId
|
||||
abortController?: AbortController
|
||||
},
|
||||
): string {
|
||||
const id = generateTaskId('local_workflow')
|
||||
const task: LocalWorkflowTaskState = {
|
||||
...createTaskStateBase(id, 'local_workflow', opts.description, opts.toolUseId),
|
||||
type: 'local_workflow',
|
||||
status: 'running',
|
||||
workflowName: opts.workflowName,
|
||||
workflowFile: opts.workflowFile,
|
||||
summary: opts.summary,
|
||||
agentId: opts.agentId,
|
||||
abortController: opts.abortController,
|
||||
}
|
||||
registerTask(task, setAppState)
|
||||
return id
|
||||
}
|
||||
|
||||
export function completeWorkflowTask(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
updateTaskState<LocalWorkflowTaskState>(taskId, setAppState, task => ({
|
||||
...task,
|
||||
status: 'completed',
|
||||
endTime: Date.now(),
|
||||
notified: true,
|
||||
abortController: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export function failWorkflowTask(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
updateTaskState<LocalWorkflowTaskState>(taskId, setAppState, task => ({
|
||||
...task,
|
||||
status: 'failed',
|
||||
endTime: Date.now(),
|
||||
notified: true,
|
||||
abortController: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a running workflow task. Called from BackgroundTasksDialog
|
||||
* via the feature-gated `killWorkflowTask` binding.
|
||||
*/
|
||||
export function killWorkflowTask(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
updateTaskState<LocalWorkflowTaskState>(taskId, setAppState, task => {
|
||||
if (task.status !== 'running') return task
|
||||
task.abortController?.abort()
|
||||
return {
|
||||
...task,
|
||||
status: 'killed',
|
||||
endTime: Date.now(),
|
||||
notified: true,
|
||||
abortController: undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the current agent step within a running workflow.
|
||||
* Called from BackgroundTasksDialog via the feature-gated
|
||||
* `skipWorkflowAgent` binding: skipWorkflowAgent(taskId, agentId, setAppState).
|
||||
*/
|
||||
export function skipWorkflowAgent(
|
||||
taskId: string,
|
||||
agentId: AgentId,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
logForDebugging(
|
||||
`skipWorkflowAgent: skipping agent ${agentId} in workflow task ${taskId}`,
|
||||
)
|
||||
updateTaskState<LocalWorkflowTaskState>(taskId, setAppState, task => {
|
||||
if (task.status !== 'running') return task
|
||||
return {
|
||||
...task,
|
||||
pendingAgentAction: {
|
||||
kind: 'skip',
|
||||
agentId,
|
||||
requestedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry the current agent step within a running workflow.
|
||||
* Called from BackgroundTasksDialog via the feature-gated
|
||||
* `retryWorkflowAgent` binding: retryWorkflowAgent(taskId, agentId, setAppState).
|
||||
*/
|
||||
export function retryWorkflowAgent(
|
||||
taskId: string,
|
||||
agentId: AgentId,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
logForDebugging(
|
||||
`retryWorkflowAgent: retrying agent ${agentId} in workflow task ${taskId}`,
|
||||
)
|
||||
updateTaskState<LocalWorkflowTaskState>(taskId, setAppState, task => {
|
||||
if (task.status !== 'running') return task
|
||||
return {
|
||||
...task,
|
||||
pendingAgentAction: {
|
||||
kind: 'retry',
|
||||
agentId,
|
||||
requestedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all running workflow tasks spawned by a given agent.
|
||||
* Called from runAgent.ts finally block.
|
||||
*/
|
||||
export function killWorkflowTasksForAgent(
|
||||
agentId: AgentId,
|
||||
getAppState: () => AppState,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
const tasks = getAppState().tasks ?? {}
|
||||
for (const [taskId, task] of Object.entries(tasks)) {
|
||||
if (
|
||||
isLocalWorkflowTask(task) &&
|
||||
task.agentId === agentId &&
|
||||
task.status === 'running'
|
||||
) {
|
||||
logForDebugging(
|
||||
`killWorkflowTasksForAgent: killing orphaned workflow task ${taskId} (agent ${agentId} exiting)`,
|
||||
)
|
||||
killWorkflowTask(taskId, setAppState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LocalWorkflowTask: Task = {
|
||||
name: 'LocalWorkflowTask',
|
||||
type: 'local_workflow',
|
||||
async kill(taskId: string, setAppState: SetAppState) {
|
||||
killWorkflowTask(taskId, setAppState)
|
||||
},
|
||||
}
|
||||
export const killWorkflowTask: (id: string, setAppState: SetAppState) => void = (() => {});
|
||||
export const skipWorkflowAgent: (id: string, agentId: string, setAppState: SetAppState) => void = (() => {});
|
||||
export const retryWorkflowAgent: (id: string, agentId: string, setAppState: SetAppState) => void = (() => {});
|
||||
|
||||
@@ -1,10 +1,139 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { TaskStateBase, SetAppState } from '../../Task.js';
|
||||
import type { AppState } from '../../state/AppState.js';
|
||||
import type { AgentId } from '../../types/ids.js';
|
||||
// Background task entry for MCP resource monitoring.
|
||||
// Tracks a long-running subscription to an MCP server resource so the
|
||||
// otherwise-invisible stream is visible in the footer pill and Shift+Down
|
||||
// dialog. Follows the DreamTask pattern: pure UI surfacing via the existing
|
||||
// task registry.
|
||||
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import type { SetAppState, Task, TaskStateBase } from '../../Task.js'
|
||||
import { createTaskStateBase, generateTaskId } from '../../Task.js'
|
||||
import type { AgentId } from '../../types/ids.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { registerTask, updateTaskState } from '../../utils/task/framework.js'
|
||||
|
||||
export type MonitorMcpTaskState = TaskStateBase & {
|
||||
type: 'monitor_mcp';
|
||||
};
|
||||
export const killMonitorMcp: (taskId: string, setAppState: SetAppState) => void = (() => {});
|
||||
export const killMonitorMcpTasksForAgent: (agentId: AgentId, getAppState: () => AppState, setAppState: SetAppState) => void = (() => {});
|
||||
type: 'monitor_mcp'
|
||||
/** The MCP server name being monitored. */
|
||||
serverName: string
|
||||
/** The resource URI being subscribed to. */
|
||||
resourceUri: string
|
||||
/** The shell command used to drive monitoring (if any). */
|
||||
command?: string
|
||||
/** Agent that spawned this task. Used to kill orphaned tasks on agent exit. */
|
||||
agentId?: AgentId
|
||||
/** Abort controller to cancel the subscription. */
|
||||
abortController?: AbortController
|
||||
}
|
||||
|
||||
export function isMonitorMcpTask(task: unknown): task is MonitorMcpTaskState {
|
||||
return (
|
||||
typeof task === 'object' &&
|
||||
task !== null &&
|
||||
'type' in task &&
|
||||
task.type === 'monitor_mcp'
|
||||
)
|
||||
}
|
||||
|
||||
export function registerMonitorMcpTask(
|
||||
setAppState: SetAppState,
|
||||
opts: {
|
||||
description: string
|
||||
serverName: string
|
||||
resourceUri: string
|
||||
command?: string
|
||||
toolUseId?: string
|
||||
agentId?: AgentId
|
||||
abortController?: AbortController
|
||||
},
|
||||
): string {
|
||||
const id = generateTaskId('monitor_mcp')
|
||||
const task: MonitorMcpTaskState = {
|
||||
...createTaskStateBase(id, 'monitor_mcp', opts.description, opts.toolUseId),
|
||||
type: 'monitor_mcp',
|
||||
status: 'running',
|
||||
serverName: opts.serverName,
|
||||
resourceUri: opts.resourceUri,
|
||||
command: opts.command,
|
||||
agentId: opts.agentId,
|
||||
abortController: opts.abortController,
|
||||
}
|
||||
registerTask(task, setAppState)
|
||||
return id
|
||||
}
|
||||
|
||||
export function completeMonitorMcpTask(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
updateTaskState<MonitorMcpTaskState>(taskId, setAppState, task => ({
|
||||
...task,
|
||||
status: 'completed',
|
||||
endTime: Date.now(),
|
||||
notified: true,
|
||||
abortController: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export function failMonitorMcpTask(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
updateTaskState<MonitorMcpTaskState>(taskId, setAppState, task => ({
|
||||
...task,
|
||||
status: 'failed',
|
||||
endTime: Date.now(),
|
||||
notified: true,
|
||||
abortController: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export function killMonitorMcp(
|
||||
taskId: string,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
updateTaskState<MonitorMcpTaskState>(taskId, setAppState, task => {
|
||||
if (task.status !== 'running') return task
|
||||
task.abortController?.abort()
|
||||
return {
|
||||
...task,
|
||||
status: 'killed',
|
||||
endTime: Date.now(),
|
||||
notified: true,
|
||||
abortController: undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all running monitor_mcp tasks spawned by a given agent.
|
||||
* Called from runAgent.ts finally block so subscriptions don't outlive
|
||||
* the agent that started them.
|
||||
*/
|
||||
export function killMonitorMcpTasksForAgent(
|
||||
agentId: AgentId,
|
||||
getAppState: () => AppState,
|
||||
setAppState: SetAppState,
|
||||
): void {
|
||||
const tasks = getAppState().tasks ?? {}
|
||||
for (const [taskId, task] of Object.entries(tasks)) {
|
||||
if (
|
||||
isMonitorMcpTask(task) &&
|
||||
task.agentId === agentId &&
|
||||
task.status === 'running'
|
||||
) {
|
||||
logForDebugging(
|
||||
`killMonitorMcpTasksForAgent: killing orphaned monitor task ${taskId} (agent ${agentId} exiting)`,
|
||||
)
|
||||
killMonitorMcp(taskId, setAppState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MonitorMcpTask: Task = {
|
||||
name: 'MonitorMcpTask',
|
||||
type: 'monitor_mcp',
|
||||
|
||||
async kill(taskId, setAppState) {
|
||||
killMonitorMcp(taskId, setAppState)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ const coordinatorModeModule = feature('COORDINATOR_MODE')
|
||||
const SnipTool = feature('HISTORY_SNIP')
|
||||
? require('./tools/SnipTool/SnipTool.js').SnipTool
|
||||
: null
|
||||
const ReviewArtifactTool = feature('REVIEW_ARTIFACT')
|
||||
? require('./tools/ReviewArtifactTool/ReviewArtifactTool.js')
|
||||
.ReviewArtifactTool
|
||||
: null
|
||||
const ListPeersTool = feature('UDS_INBOX')
|
||||
? require('./tools/ListPeersTool/ListPeersTool.js').ListPeersTool
|
||||
: null
|
||||
@@ -237,6 +241,7 @@ export function getAllBaseTools(): Tools {
|
||||
...(SendUserFileTool ? [SendUserFileTool] : []),
|
||||
...(PushNotificationTool ? [PushNotificationTool] : []),
|
||||
...(SubscribePRTool ? [SubscribePRTool] : []),
|
||||
...(ReviewArtifactTool ? [ReviewArtifactTool] : []),
|
||||
...(getPowerShellTool() ? [getPowerShellTool()] : []),
|
||||
...(SnipTool ? [SnipTool] : []),
|
||||
...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),
|
||||
|
||||
80
src/tools/CtxInspectTool/CtxInspectTool.ts
Normal file
80
src/tools/CtxInspectTool/CtxInspectTool.ts
Normal 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.',
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
107
src/tools/ListPeersTool/ListPeersTool.ts
Normal file
107
src/tools/ListPeersTool/ListPeersTool.ts
Normal 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 },
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const MonitorTool: Record<string, unknown> = {};
|
||||
190
src/tools/MonitorTool/MonitorTool.tsx
Normal file
190
src/tools/MonitorTool/MonitorTool.tsx
Normal 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>
|
||||
},
|
||||
})
|
||||
@@ -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", () => {
|
||||
|
||||
87
src/tools/PushNotificationTool/PushNotificationTool.ts
Normal file
87
src/tools/PushNotificationTool/PushNotificationTool.ts
Normal 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.',
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export const REPLTool = { name: 'REPLTool', isEnabled: () => false }
|
||||
89
src/tools/REPLTool/REPLTool.ts
Normal file
89
src/tools/REPLTool/REPLTool.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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>)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
84
src/tools/SendUserFileTool/SendUserFileTool.ts
Normal file
84
src/tools/SendUserFileTool/SendUserFileTool.ts
Normal 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.',
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
134
src/tools/SleepTool/SleepTool.ts
Normal file
134
src/tools/SleepTool/SleepTool.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
92
src/tools/SnipTool/SnipTool.ts
Normal file
92
src/tools/SnipTool/SnipTool.ts
Normal 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`,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,3 +1 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const SNIP_TOOL_NAME: string = '';
|
||||
export const SNIP_TOOL_NAME = 'Snip'
|
||||
|
||||
88
src/tools/SubscribePRTool/SubscribePRTool.ts
Normal file
88
src/tools/SubscribePRTool/SubscribePRTool.ts
Normal 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.',
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export const SuggestBackgroundPRTool = { name: 'SuggestBackgroundPRTool', isEnabled: () => false }
|
||||
84
src/tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.ts
Normal file
84
src/tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.ts
Normal 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.',
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
82
src/tools/TerminalCaptureTool/TerminalCaptureTool.ts
Normal file
82
src/tools/TerminalCaptureTool/TerminalCaptureTool.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const VerifyPlanExecutionTool = { name: 'VerifyPlanExecutionTool', isEnabled: () => false }
|
||||
93
src/tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.ts
Normal file
93
src/tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
97
src/tools/WebBrowserTool/WebBrowserTool.ts
Normal file
97
src/tools/WebBrowserTool/WebBrowserTool.ts
Normal 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.',
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const WorkflowPermissionRequest: (props: Record<string, unknown>) => null = () => null;
|
||||
166
src/tools/WorkflowTool/WorkflowPermissionRequest.tsx
Normal file
166
src/tools/WorkflowTool/WorkflowPermissionRequest.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
15
src/tools/WorkflowTool/bundled/index.ts
Normal file
15
src/tools/WorkflowTool/bundled/index.ts
Normal 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.
|
||||
}
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
165
src/utils/__tests__/lanBeacon.test.ts
Normal file
165
src/utils/__tests__/lanBeacon.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'
|
||||
|
||||
// Mock dgram before importing LanBeacon
|
||||
const mockSocket = {
|
||||
on: mock(() => mockSocket),
|
||||
bind: mock((port: number, cb: () => void) => cb()),
|
||||
addMembership: mock(() => {}),
|
||||
setMulticastInterface: mock(() => {}),
|
||||
setMulticastTTL: mock(() => {}),
|
||||
setBroadcast: mock(() => {}),
|
||||
dropMembership: mock(() => {}),
|
||||
send: mock(() => {}),
|
||||
close: mock(() => {}),
|
||||
}
|
||||
|
||||
mock.module('dgram', () => ({
|
||||
createSocket: () => mockSocket,
|
||||
}))
|
||||
|
||||
const { LanBeacon } = await import('../lanBeacon.js')
|
||||
|
||||
type MockCall = [string, ...unknown[]]
|
||||
|
||||
function getMessageHandler(): ((msg: Buffer, rinfo: { address: string; port: number }) => void) | undefined {
|
||||
const calls = mockSocket.on.mock.calls as unknown as MockCall[]
|
||||
const call = calls.find(c => c[0] === 'message')
|
||||
return call?.[1] as ((msg: Buffer, rinfo: { address: string; port: number }) => void) | undefined
|
||||
}
|
||||
|
||||
describe('LanBeacon', () => {
|
||||
let beacon: InstanceType<typeof LanBeacon>
|
||||
|
||||
const announceData = {
|
||||
pipeName: 'cli-test1234',
|
||||
machineId: 'machine-abc',
|
||||
hostname: 'test-host',
|
||||
ip: '192.168.1.10',
|
||||
tcpPort: 7100,
|
||||
role: 'main' as const,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockSocket.on.mockClear()
|
||||
mockSocket.bind.mockClear()
|
||||
mockSocket.send.mockClear()
|
||||
mockSocket.close.mockClear()
|
||||
mockSocket.addMembership.mockClear()
|
||||
mockSocket.dropMembership.mockClear()
|
||||
beacon = new LanBeacon(announceData)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
beacon.stop()
|
||||
})
|
||||
|
||||
test('start initializes socket and sends first announce', () => {
|
||||
beacon.start()
|
||||
expect(mockSocket.bind).toHaveBeenCalledTimes(1)
|
||||
expect(mockSocket.addMembership).toHaveBeenCalledWith(
|
||||
'224.0.71.67',
|
||||
'192.168.1.10',
|
||||
)
|
||||
expect(mockSocket.setMulticastTTL).toHaveBeenCalledWith(1)
|
||||
// First announce sent immediately
|
||||
expect(mockSocket.send).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('getPeers returns empty map initially', () => {
|
||||
beacon.start()
|
||||
expect(beacon.getPeers().size).toBe(0)
|
||||
})
|
||||
|
||||
test('stop closes socket and clears peers', () => {
|
||||
beacon.start()
|
||||
beacon.stop()
|
||||
expect(mockSocket.close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('processes incoming announce from different peer', () => {
|
||||
beacon.start()
|
||||
|
||||
const messageHandler = getMessageHandler()
|
||||
if (!messageHandler) return
|
||||
|
||||
const peerAnnounce = JSON.stringify({
|
||||
proto: 'claude-pipe-v1',
|
||||
pipeName: 'cli-peer5678',
|
||||
machineId: 'machine-xyz',
|
||||
hostname: 'peer-host',
|
||||
ip: '192.168.1.20',
|
||||
tcpPort: 7102,
|
||||
role: 'sub',
|
||||
ts: Date.now(),
|
||||
})
|
||||
|
||||
let discoveredPeer: any = null
|
||||
beacon.on('peer-discovered', (peer: any) => {
|
||||
discoveredPeer = peer
|
||||
})
|
||||
|
||||
messageHandler(Buffer.from(peerAnnounce), {
|
||||
address: '192.168.1.20',
|
||||
port: 7101,
|
||||
})
|
||||
|
||||
expect(beacon.getPeers().size).toBe(1)
|
||||
expect(beacon.getPeers().has('cli-peer5678')).toBe(true)
|
||||
expect(discoveredPeer).not.toBeNull()
|
||||
expect(discoveredPeer.pipeName).toBe('cli-peer5678')
|
||||
})
|
||||
|
||||
test('ignores self-announces', () => {
|
||||
beacon.start()
|
||||
|
||||
const messageHandler = getMessageHandler()
|
||||
if (!messageHandler) return
|
||||
|
||||
const selfAnnounce = JSON.stringify({
|
||||
proto: 'claude-pipe-v1',
|
||||
pipeName: 'cli-test1234', // same as our pipeName
|
||||
machineId: 'machine-abc',
|
||||
hostname: 'test-host',
|
||||
ip: '192.168.1.10',
|
||||
tcpPort: 7100,
|
||||
role: 'main',
|
||||
ts: Date.now(),
|
||||
})
|
||||
|
||||
messageHandler(Buffer.from(selfAnnounce), {
|
||||
address: '192.168.1.10',
|
||||
port: 7101,
|
||||
})
|
||||
expect(beacon.getPeers().size).toBe(0)
|
||||
})
|
||||
|
||||
test('ignores non-claude-pipe protocol messages', () => {
|
||||
beacon.start()
|
||||
|
||||
const messageHandler = getMessageHandler()
|
||||
if (!messageHandler) return
|
||||
|
||||
const foreignMessage = JSON.stringify({
|
||||
proto: 'something-else',
|
||||
pipeName: 'cli-foreign',
|
||||
})
|
||||
|
||||
messageHandler(Buffer.from(foreignMessage), {
|
||||
address: '192.168.1.30',
|
||||
port: 7101,
|
||||
})
|
||||
expect(beacon.getPeers().size).toBe(0)
|
||||
})
|
||||
|
||||
test('updateAnnounce changes role', () => {
|
||||
beacon.updateAnnounce({ role: 'sub' })
|
||||
beacon.start()
|
||||
// The send call should include the updated role
|
||||
const sendCalls = mockSocket.send.mock.calls as unknown as [Buffer, ...unknown[]][]
|
||||
const sendCall = sendCalls[0]
|
||||
if (sendCall) {
|
||||
const payload = JSON.parse(sendCall[0].toString())
|
||||
expect(payload.role).toBe('sub')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { tmpdir } from "os";
|
||||
import { resolve } from "path";
|
||||
import {
|
||||
getFsImplementation,
|
||||
setFsImplementation,
|
||||
setOriginalFsImplementation,
|
||||
type FsOperations,
|
||||
} from "../fsOperations";
|
||||
import {
|
||||
containsPathTraversal,
|
||||
expandPath,
|
||||
@@ -176,24 +183,67 @@ describe("toRelativePath", () => {
|
||||
|
||||
describe("getDirectoryForPath", () => {
|
||||
test("returns the path itself when given an existing directory", () => {
|
||||
// The src directory is guaranteed to exist in this repo
|
||||
const dir = resolve(process.cwd(), "src");
|
||||
const result = getDirectoryForPath(dir);
|
||||
expect(result).toBe(dir);
|
||||
setOriginalFsImplementation();
|
||||
const dir = resolve(tmpdir(), "ccb-existing-dir");
|
||||
const baseFs = getFsImplementation();
|
||||
setFsImplementation({
|
||||
...baseFs,
|
||||
statSync: ((path: string) => {
|
||||
if (path === dir) {
|
||||
return { isDirectory: () => true } as any;
|
||||
}
|
||||
return baseFs.statSync(path);
|
||||
}) as FsOperations["statSync"],
|
||||
});
|
||||
try {
|
||||
const result = getDirectoryForPath(dir);
|
||||
expect(result).toBe(dir);
|
||||
} finally {
|
||||
setOriginalFsImplementation();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns parent directory for a known file", () => {
|
||||
// package.json is at the repo root
|
||||
const file = resolve(process.cwd(), "package.json");
|
||||
const expectedParent = process.cwd();
|
||||
const result = getDirectoryForPath(file);
|
||||
expect(result).toBe(expectedParent);
|
||||
setOriginalFsImplementation();
|
||||
const expectedParent = resolve(tmpdir(), "ccb-file-parent");
|
||||
const file = resolve(expectedParent, "sample.txt");
|
||||
const baseFs = getFsImplementation();
|
||||
setFsImplementation({
|
||||
...baseFs,
|
||||
statSync: ((path: string) => {
|
||||
if (path === file) {
|
||||
return { isDirectory: () => false } as any;
|
||||
}
|
||||
return baseFs.statSync(path);
|
||||
}) as FsOperations["statSync"],
|
||||
});
|
||||
try {
|
||||
const result = getDirectoryForPath(file);
|
||||
expect(result).toBe(expectedParent);
|
||||
} finally {
|
||||
setOriginalFsImplementation();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns parent directory for a non-existent path", () => {
|
||||
const nonExistent = resolve(process.cwd(), "does-not-exist-xyz123.ts");
|
||||
const expectedParent = process.cwd();
|
||||
const result = getDirectoryForPath(nonExistent);
|
||||
expect(result).toBe(expectedParent);
|
||||
setOriginalFsImplementation();
|
||||
const expectedParent = resolve(tmpdir(), "ccb-missing-parent");
|
||||
const nonExistent = resolve(expectedParent, "does-not-exist-xyz123.ts");
|
||||
const baseFs = getFsImplementation();
|
||||
setFsImplementation({
|
||||
...baseFs,
|
||||
statSync: ((path: string) => {
|
||||
if (path === nonExistent) {
|
||||
throw new Error("ENOENT");
|
||||
}
|
||||
return baseFs.statSync(path);
|
||||
}) as FsOperations["statSync"],
|
||||
});
|
||||
try {
|
||||
const result = getDirectoryForPath(nonExistent);
|
||||
expect(result).toBe(expectedParent);
|
||||
} finally {
|
||||
setOriginalFsImplementation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
60
src/utils/__tests__/peerAddress.test.ts
Normal file
60
src/utils/__tests__/peerAddress.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { parseAddress, parseTcpTarget } from '../peerAddress.js'
|
||||
|
||||
describe('parseAddress', () => {
|
||||
test('uds: scheme', () => {
|
||||
expect(parseAddress('uds:/tmp/test.sock')).toEqual({
|
||||
scheme: 'uds',
|
||||
target: '/tmp/test.sock',
|
||||
})
|
||||
})
|
||||
|
||||
test('bridge: scheme', () => {
|
||||
expect(parseAddress('bridge:session-123')).toEqual({
|
||||
scheme: 'bridge',
|
||||
target: 'session-123',
|
||||
})
|
||||
})
|
||||
|
||||
test('tcp: scheme', () => {
|
||||
expect(parseAddress('tcp:192.168.1.20:7100')).toEqual({
|
||||
scheme: 'tcp',
|
||||
target: '192.168.1.20:7100',
|
||||
})
|
||||
})
|
||||
|
||||
test('bare path routes to uds', () => {
|
||||
expect(parseAddress('/var/run/test.sock')).toEqual({
|
||||
scheme: 'uds',
|
||||
target: '/var/run/test.sock',
|
||||
})
|
||||
})
|
||||
|
||||
test('other falls through', () => {
|
||||
expect(parseAddress('teammate-name')).toEqual({
|
||||
scheme: 'other',
|
||||
target: 'teammate-name',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTcpTarget', () => {
|
||||
test('valid host:port', () => {
|
||||
expect(parseTcpTarget('192.168.1.20:7100')).toEqual({
|
||||
host: '192.168.1.20',
|
||||
port: 7100,
|
||||
})
|
||||
})
|
||||
|
||||
test('hostname:port', () => {
|
||||
expect(parseTcpTarget('my-host:8080')).toEqual({
|
||||
host: 'my-host',
|
||||
port: 8080,
|
||||
})
|
||||
})
|
||||
|
||||
test('invalid format returns null', () => {
|
||||
expect(parseTcpTarget('no-port')).toBeNull()
|
||||
expect(parseTcpTarget('')).toBeNull()
|
||||
})
|
||||
})
|
||||
76
src/utils/__tests__/pipePermissionRelay.test.ts
Normal file
76
src/utils/__tests__/pipePermissionRelay.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
clearPendingPipePermissions,
|
||||
resolvePipePermissionResponse,
|
||||
tryRelayPipePermissionRequest,
|
||||
setPipeRelay,
|
||||
} from '../pipePermissionRelay.js'
|
||||
|
||||
afterEach(() => {
|
||||
setPipeRelay(null)
|
||||
clearPendingPipePermissions()
|
||||
})
|
||||
|
||||
function makeToolUseConfirm(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
assistantMessage: { message: { id: 'msg-1' } },
|
||||
tool: { name: 'Bash' },
|
||||
description: 'Run command',
|
||||
input: { command: 'echo hello' },
|
||||
toolUseID: 'tool-1',
|
||||
permissionResult: { behavior: 'ask', message: 'Approve?' },
|
||||
permissionPromptStartTimeMs: 1,
|
||||
...overrides,
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('pipe permission relay', () => {
|
||||
test('serializes permission requests through the active pipe sender', () => {
|
||||
const sent: any[] = []
|
||||
setPipeRelay((message: any) => {
|
||||
sent.push(message)
|
||||
})
|
||||
|
||||
const requestId = tryRelayPipePermissionRequest(
|
||||
makeToolUseConfirm(),
|
||||
() => {},
|
||||
)
|
||||
|
||||
expect(requestId).toBeString()
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0].type).toBe('permission_request')
|
||||
const payload = JSON.parse(sent[0].data)
|
||||
expect(payload.requestId).toBe(requestId)
|
||||
expect(payload.toolName).toBe('Bash')
|
||||
expect(payload.input).toEqual({ command: 'echo hello' })
|
||||
})
|
||||
|
||||
test('dispatches permission responses to the pending request handler', () => {
|
||||
setPipeRelay(() => {})
|
||||
const seen: any[] = []
|
||||
const requestId = tryRelayPipePermissionRequest(
|
||||
makeToolUseConfirm(),
|
||||
payload => {
|
||||
seen.push(payload)
|
||||
},
|
||||
)
|
||||
|
||||
expect(requestId).toBeString()
|
||||
const resolved = resolvePipePermissionResponse({
|
||||
requestId: requestId!,
|
||||
behavior: 'allow',
|
||||
updatedInput: { command: 'echo ok' },
|
||||
permissionUpdates: [],
|
||||
})
|
||||
|
||||
expect(resolved).toBe(true)
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
requestId,
|
||||
behavior: 'allow',
|
||||
updatedInput: { command: 'echo ok' },
|
||||
permissionUpdates: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
53
src/utils/__tests__/pipeTransport.test.ts
Normal file
53
src/utils/__tests__/pipeTransport.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
getPipeDisplayRole,
|
||||
isPipeControlled,
|
||||
type PipeIpcState,
|
||||
} from '../pipeTransport.js'
|
||||
|
||||
function makePipeState(overrides: Partial<PipeIpcState> = {}): PipeIpcState {
|
||||
return {
|
||||
role: 'main',
|
||||
subIndex: null,
|
||||
displayRole: 'main',
|
||||
serverName: 'cli-main',
|
||||
attachedBy: null,
|
||||
localIp: null,
|
||||
hostname: null,
|
||||
machineId: null,
|
||||
mac: null,
|
||||
statusVisible: false,
|
||||
selectorOpen: false,
|
||||
selectedPipes: [],
|
||||
routeMode: 'selected',
|
||||
slaves: {},
|
||||
discoveredPipes: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('pipe transport role helpers', () => {
|
||||
test('keeps controlled subs on their sub-N display role', () => {
|
||||
const state = makePipeState({
|
||||
role: 'sub',
|
||||
subIndex: 2,
|
||||
displayRole: 'slave',
|
||||
attachedBy: 'cli-master',
|
||||
})
|
||||
|
||||
expect(isPipeControlled(state)).toBe(true)
|
||||
expect(getPipeDisplayRole(state)).toBe('sub-2')
|
||||
})
|
||||
|
||||
test('preserves master and main display roles', () => {
|
||||
expect(getPipeDisplayRole(makePipeState())).toBe('main')
|
||||
expect(
|
||||
getPipeDisplayRole(
|
||||
makePipeState({
|
||||
role: 'master',
|
||||
displayRole: 'main',
|
||||
}),
|
||||
),
|
||||
).toBe('master')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
|
||||
mock.module("src/ink/stringWidth.js", () => ({
|
||||
stringWidth: (str: string) => {
|
||||
let width = 0;
|
||||
for (const char of str) {
|
||||
const code = char.codePointAt(0)!;
|
||||
if (
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0x3000 && code <= 0x303f) ||
|
||||
(code >= 0xff01 && code <= 0xff60) ||
|
||||
(code >= 0xf900 && code <= 0xfaff)
|
||||
) {
|
||||
width += 2;
|
||||
} else if (code >= 0x1f300 && code <= 0x1faff) {
|
||||
width += 2;
|
||||
} else if (code > 0) {
|
||||
width += 1;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
},
|
||||
}));
|
||||
import {
|
||||
truncatePathMiddle,
|
||||
truncateToWidth,
|
||||
|
||||
@@ -1434,6 +1434,7 @@ export async function shouldShowClaudeMdExternalIncludesWarning(): Promise<boole
|
||||
*/
|
||||
export function isMemoryFilePath(filePath: string): boolean {
|
||||
const name = basename(filePath)
|
||||
const normalizedPath = normalizePathForComparison(filePath)
|
||||
|
||||
// CLAUDE.md or CLAUDE.local.md anywhere
|
||||
if (name === 'CLAUDE.md' || name === 'CLAUDE.local.md') {
|
||||
@@ -1443,7 +1444,7 @@ export function isMemoryFilePath(filePath: string): boolean {
|
||||
// .md files in .claude/rules/ directories
|
||||
if (
|
||||
name.endsWith('.md') &&
|
||||
filePath.includes(`${sep}.claude${sep}rules${sep}`)
|
||||
normalizedPath.includes('/.claude/rules/')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -566,11 +566,13 @@ export function normalizePathForComparison(filePath: string): string {
|
||||
// Use path.normalize() to clean up redundant separators and resolve . and ..
|
||||
let normalized = normalize(filePath)
|
||||
|
||||
// On Windows, normalize for case-insensitive comparison:
|
||||
// - Convert forward slashes to backslashes (path.normalize only does this on actual Windows)
|
||||
// - Convert to lowercase (Windows paths are case-insensitive)
|
||||
// Convert separators to a stable slash form so comparison behavior stays
|
||||
// consistent across platforms and in tests that use POSIX-style fixtures.
|
||||
normalized = normalized.replace(/\\/g, '/')
|
||||
|
||||
// On Windows, normalize case for case-insensitive comparison.
|
||||
if (getPlatform() === 'windows') {
|
||||
normalized = normalized.replace(/\//g, '\\').toLowerCase()
|
||||
normalized = normalized.toLowerCase()
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
205
src/utils/lanBeacon.ts
Normal file
205
src/utils/lanBeacon.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* LAN Beacon — UDP multicast peer discovery for Pipes system.
|
||||
*
|
||||
* Uses multicast group 224.0.71.67 ("CC" = Claude Code ASCII) on port 7101
|
||||
* to announce and discover CLI instances on the local network.
|
||||
*
|
||||
* Feature-gated by LAN_PIPES.
|
||||
*/
|
||||
|
||||
import { createSocket, type Socket as DgramSocket } from 'dgram'
|
||||
import { EventEmitter } from 'events'
|
||||
import { logError } from './log.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MULTICAST_GROUP = '224.0.71.67'
|
||||
const MULTICAST_PORT = 7101
|
||||
const ANNOUNCE_INTERVAL_MS = 3000
|
||||
const PEER_TIMEOUT_MS = 15000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LanAnnounce = {
|
||||
proto: 'claude-pipe-v1'
|
||||
pipeName: string
|
||||
machineId: string
|
||||
hostname: string
|
||||
ip: string
|
||||
tcpPort: number
|
||||
role: 'main' | 'sub'
|
||||
ts: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LanBeacon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level singleton — avoids (state as any)._lanBeacon hack
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _lanBeaconInstance: LanBeacon | null = null
|
||||
|
||||
export function getLanBeacon(): LanBeacon | null {
|
||||
return _lanBeaconInstance
|
||||
}
|
||||
|
||||
export function setLanBeacon(instance: LanBeacon | null): void {
|
||||
_lanBeaconInstance = instance
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LanBeacon class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class LanBeacon extends EventEmitter {
|
||||
private socket: DgramSocket | null = null
|
||||
private announceTimer: ReturnType<typeof setInterval> | null = null
|
||||
private cleanupTimer: ReturnType<typeof setInterval> | null = null
|
||||
private peers: Map<string, LanAnnounce> = new Map()
|
||||
private announce: LanAnnounce
|
||||
|
||||
constructor(announce: Omit<LanAnnounce, 'proto' | 'ts'>) {
|
||||
super()
|
||||
this.announce = {
|
||||
...announce,
|
||||
proto: 'claude-pipe-v1',
|
||||
ts: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start broadcasting announcements and listening for peers.
|
||||
*/
|
||||
start(): void {
|
||||
if (this.socket) return
|
||||
|
||||
try {
|
||||
this.socket = createSocket({ type: 'udp4', reuseAddr: true })
|
||||
|
||||
this.socket.on('error', err => {
|
||||
logError(err)
|
||||
// Non-fatal — multicast may not be supported on this network
|
||||
})
|
||||
|
||||
this.socket.on('message', (buf, rinfo) => {
|
||||
try {
|
||||
const msg = JSON.parse(buf.toString()) as LanAnnounce
|
||||
if (msg.proto !== 'claude-pipe-v1') return
|
||||
if (msg.pipeName === this.announce.pipeName) return // ignore self
|
||||
|
||||
const isNew = !this.peers.has(msg.pipeName)
|
||||
this.peers.set(msg.pipeName, { ...msg, ts: Date.now() })
|
||||
|
||||
if (isNew) {
|
||||
this.emit('peer-discovered', msg)
|
||||
}
|
||||
} catch {
|
||||
// Malformed packet — ignore
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.bind(MULTICAST_PORT, () => {
|
||||
try {
|
||||
// Specify the local LAN interface for multicast membership.
|
||||
// Without this, Windows may bind to a WSL/Docker virtual adapter
|
||||
// and multicast packets never reach the real LAN.
|
||||
const localIp = this.announce.ip
|
||||
this.socket!.addMembership(MULTICAST_GROUP, localIp)
|
||||
this.socket!.setMulticastInterface(localIp)
|
||||
this.socket!.setMulticastTTL(1) // link-local only
|
||||
this.socket!.setBroadcast(true)
|
||||
} catch (err) {
|
||||
logError(err as Error)
|
||||
}
|
||||
|
||||
// Start announce + cleanup timers after socket is fully bound
|
||||
this.announceTimer = setInterval(
|
||||
() => this.sendAnnounce(),
|
||||
ANNOUNCE_INTERVAL_MS,
|
||||
)
|
||||
// Send first announce immediately
|
||||
this.sendAnnounce()
|
||||
|
||||
// Periodic cleanup of stale peers
|
||||
this.cleanupTimer = setInterval(
|
||||
() => this.cleanupStalePeers(),
|
||||
PEER_TIMEOUT_MS / 2,
|
||||
)
|
||||
})
|
||||
} catch (err) {
|
||||
logError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop broadcasting and close the socket.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.announceTimer) {
|
||||
clearInterval(this.announceTimer)
|
||||
this.announceTimer = null
|
||||
}
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer)
|
||||
this.cleanupTimer = null
|
||||
}
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.dropMembership(MULTICAST_GROUP)
|
||||
} catch {
|
||||
// May fail if socket already closed
|
||||
}
|
||||
this.socket.close()
|
||||
this.socket = null
|
||||
}
|
||||
this.peers.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currently known peers (excluding self).
|
||||
*/
|
||||
getPeers(): Map<string, LanAnnounce> {
|
||||
return new Map(this.peers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the announce data (e.g., when role changes).
|
||||
*/
|
||||
updateAnnounce(partial: Partial<Omit<LanAnnounce, 'proto' | 'ts'>>): void {
|
||||
this.announce = { ...this.announce, ...partial }
|
||||
}
|
||||
|
||||
private sendAnnounce(): void {
|
||||
if (!this.socket) return
|
||||
try {
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ ...this.announce, ts: Date.now() }),
|
||||
)
|
||||
this.socket.send(
|
||||
payload,
|
||||
0,
|
||||
payload.length,
|
||||
MULTICAST_PORT,
|
||||
MULTICAST_GROUP,
|
||||
)
|
||||
} catch {
|
||||
// Send failure — non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupStalePeers(): void {
|
||||
const now = Date.now()
|
||||
for (const [name, peer] of this.peers) {
|
||||
if (now - peer.ts > PEER_TIMEOUT_MS) {
|
||||
this.peers.delete(name)
|
||||
this.emit('peer-lost', name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/utils/ndjsonFramer.ts
Normal file
39
src/utils/ndjsonFramer.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Shared NDJSON (Newline-Delimited JSON) socket framing.
|
||||
*
|
||||
* Accumulates incoming data chunks, splits on newlines, and emits
|
||||
* parsed JSON objects. Used by both pipeTransport (UDS+TCP) and
|
||||
* udsMessaging to avoid duplicating the same buffer logic.
|
||||
*/
|
||||
import type { Socket } from 'net'
|
||||
|
||||
/**
|
||||
* Attach an NDJSON framer to a socket. Calls `onMessage` for each
|
||||
* complete JSON line received. Malformed lines are silently skipped.
|
||||
*
|
||||
* @param parse - Optional custom JSON parser (defaults to JSON.parse).
|
||||
* Useful when the caller uses a wrapped parser like jsonParse
|
||||
* from slowOperations.
|
||||
*/
|
||||
export function attachNdjsonFramer<T = unknown>(
|
||||
socket: Socket,
|
||||
onMessage: (msg: T) => void,
|
||||
parse: (text: string) => T = text => JSON.parse(text) as T,
|
||||
): void {
|
||||
let buffer = ''
|
||||
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
buffer += chunk.toString()
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
onMessage(parse(line))
|
||||
} catch {
|
||||
// Malformed JSON — skip
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user