feat: 远程群控 (#243)

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

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

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

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

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

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

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

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

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

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

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

* fix: 恢复 /poor 的说明

---------

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

View File

@@ -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
}

View 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()
}

View 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

View 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 },
})
})
}

View 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

View 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') }
}

View 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

View 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

View 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.`,
}
}

View 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

View 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

View 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}]`
}
}

View 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
View 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

View File

@@ -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

View 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`
}

View 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

View 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') }
}

View 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
View 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
View 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

View 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
View 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)}`,
}
}
}

View 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
View File

@@ -0,0 +1 @@
export default null

View File

@@ -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