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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user