@office-xyz/claude-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/index.js +1226 -0
- package/onboarding.js +379 -0
- package/package.json +56 -0
package/index.js
ADDED
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3
|
+
// @virtual-office/local-host — Connect a local CLI agent to Virtual Office
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// npx @virtual-office/local-host \
|
|
7
|
+
// --agent claude.aladdin.office.xyz \
|
|
8
|
+
// --token eyJhbGci... \
|
|
9
|
+
// --provider claude-code
|
|
10
|
+
//
|
|
11
|
+
// The adapter connects to the Manager WebSocket, runs Claude Code in
|
|
12
|
+
// headless mode (-p --output-format stream-json), and bridges streaming
|
|
13
|
+
// responses between the office and the local CLI process.
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
import { WebSocket } from 'ws'
|
|
16
|
+
import chalk from 'chalk'
|
|
17
|
+
import yargs from 'yargs'
|
|
18
|
+
import { hideBin } from 'yargs/helpers'
|
|
19
|
+
import { spawn } from 'child_process'
|
|
20
|
+
import { createInterface } from 'readline'
|
|
21
|
+
import { writeFileSync, readFileSync, mkdtempSync } from 'fs'
|
|
22
|
+
import { execSync } from 'child_process'
|
|
23
|
+
import path from 'path'
|
|
24
|
+
import os from 'os'
|
|
25
|
+
import { fileURLToPath } from 'url'
|
|
26
|
+
|
|
27
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
28
|
+
const __dirname = path.dirname(__filename)
|
|
29
|
+
|
|
30
|
+
// ── CLI arguments ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
// In production, connect through Chat Bridge's /ws/host proxy.
|
|
33
|
+
// For local dev, override with --manager ws://localhost:4789
|
|
34
|
+
const DEFAULT_MANAGER_URL =
|
|
35
|
+
process.env.MANAGER_HOST_URL ||
|
|
36
|
+
process.env.MANAGER_URL ||
|
|
37
|
+
process.env.CHAT_BRIDGE_WS_URL ||
|
|
38
|
+
'wss://chatbridge.aladdinagi.xyz/ws/host'
|
|
39
|
+
|
|
40
|
+
const argv = yargs(hideBin(process.argv))
|
|
41
|
+
.usage('$0 — Connect Claude Code to your Virtual Office')
|
|
42
|
+
.option('agent', {
|
|
43
|
+
alias: 'a',
|
|
44
|
+
type: 'string',
|
|
45
|
+
describe: 'Agent handle for direct connect (e.g. claude.aladdin.office.xyz)',
|
|
46
|
+
})
|
|
47
|
+
.option('token', {
|
|
48
|
+
alias: 't',
|
|
49
|
+
type: 'string',
|
|
50
|
+
describe: 'Connection token for direct connect',
|
|
51
|
+
default: process.env.VO_TOKEN || undefined,
|
|
52
|
+
})
|
|
53
|
+
.option('manager', {
|
|
54
|
+
alias: 'M',
|
|
55
|
+
type: 'string',
|
|
56
|
+
describe: 'Manager WebSocket URL',
|
|
57
|
+
default: DEFAULT_MANAGER_URL,
|
|
58
|
+
})
|
|
59
|
+
.option('provider', {
|
|
60
|
+
type: 'string',
|
|
61
|
+
describe: 'CLI provider: claude-code, codex, gemini',
|
|
62
|
+
default: 'claude-code',
|
|
63
|
+
})
|
|
64
|
+
.option('model', {
|
|
65
|
+
alias: 'm',
|
|
66
|
+
type: 'string',
|
|
67
|
+
describe: 'Model to pass to the CLI agent',
|
|
68
|
+
})
|
|
69
|
+
.option('workspace', {
|
|
70
|
+
alias: 'w',
|
|
71
|
+
type: 'string',
|
|
72
|
+
describe: 'Working directory for the agent',
|
|
73
|
+
default: process.cwd(),
|
|
74
|
+
})
|
|
75
|
+
.option('cli-command', {
|
|
76
|
+
type: 'string',
|
|
77
|
+
describe: 'Override the CLI binary (e.g. /usr/local/bin/claude)',
|
|
78
|
+
})
|
|
79
|
+
.example('$0', 'Interactive setup (login, create office, name agent)')
|
|
80
|
+
.example('$0 --agent claude.my.office.xyz --token xxx', 'Direct connect (skip login)')
|
|
81
|
+
.help()
|
|
82
|
+
.parse()
|
|
83
|
+
|
|
84
|
+
// ── Provider configs ───────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const PROVIDERS = {
|
|
87
|
+
'claude-code': {
|
|
88
|
+
command: 'claude',
|
|
89
|
+
// -p = headless print mode (non-interactive)
|
|
90
|
+
// --output-format stream-json = NDJSON streaming for real-time token output
|
|
91
|
+
// --verbose --include-partial-messages = emit text_delta events as they arrive
|
|
92
|
+
// --dangerously-skip-permissions = no permission prompts in headless mode
|
|
93
|
+
baseArgs: [
|
|
94
|
+
'-p',
|
|
95
|
+
'--output-format', 'stream-json',
|
|
96
|
+
'--verbose',
|
|
97
|
+
'--include-partial-messages',
|
|
98
|
+
'--dangerously-skip-permissions',
|
|
99
|
+
],
|
|
100
|
+
modelFlag: '--model',
|
|
101
|
+
defaultModel: 'claude-opus-4-6',
|
|
102
|
+
envCheck: null, // Claude Code uses local session (claude login), not API key
|
|
103
|
+
installHint: 'npm install -g @anthropic-ai/claude-code',
|
|
104
|
+
// --resume SESSION_ID to continue conversations
|
|
105
|
+
resumeFlag: '--resume',
|
|
106
|
+
},
|
|
107
|
+
codex: {
|
|
108
|
+
command: 'codex',
|
|
109
|
+
baseArgs: [],
|
|
110
|
+
modelFlag: '--model',
|
|
111
|
+
defaultModel: 'o4-mini',
|
|
112
|
+
envCheck: 'OPENAI_API_KEY',
|
|
113
|
+
installHint: 'npm install -g @openai/codex',
|
|
114
|
+
resumeFlag: null,
|
|
115
|
+
},
|
|
116
|
+
gemini: {
|
|
117
|
+
command: 'gemini',
|
|
118
|
+
baseArgs: [],
|
|
119
|
+
modelFlag: '--model',
|
|
120
|
+
defaultModel: 'gemini-2.5-pro',
|
|
121
|
+
envCheck: 'GEMINI_API_KEY',
|
|
122
|
+
installHint: 'npm install -g @google/gemini-cli',
|
|
123
|
+
resumeFlag: null,
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const providerConfig = PROVIDERS[argv.provider]
|
|
128
|
+
if (!providerConfig) {
|
|
129
|
+
console.error(chalk.red(`Unknown provider "${argv.provider}". Supported: ${Object.keys(PROVIDERS).join(', ')}`))
|
|
130
|
+
process.exit(1)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Shell-escape a string using single quotes.
|
|
137
|
+
* Single-quoting prevents ALL shell interpretation (no $, `, (, ), etc.).
|
|
138
|
+
* The only char that can't appear inside single quotes is ' itself,
|
|
139
|
+
* which is handled by: end quote, escaped quote, start quote → '\''
|
|
140
|
+
*/
|
|
141
|
+
function shellEscapeArg(s) {
|
|
142
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Logging ────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
const label = argv.agent.split('.')[0] || argv.agent
|
|
148
|
+
function log(...args) {
|
|
149
|
+
console.log(chalk.dim(`[${new Date().toISOString()}][${label}]`), ...args)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── MCP Server Pre-test ───────────────────────────────────────────────────
|
|
153
|
+
/**
|
|
154
|
+
* Quick test: spawn the MCP server and check if it responds within 5s.
|
|
155
|
+
* If it hangs or crashes, return false so we skip --mcp-config.
|
|
156
|
+
*/
|
|
157
|
+
async function testMcpServer(configPath) {
|
|
158
|
+
try {
|
|
159
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'))
|
|
160
|
+
const serverConfig = Object.values(config.mcpServers || {})[0]
|
|
161
|
+
if (!serverConfig) return false
|
|
162
|
+
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const timeout = setTimeout(() => {
|
|
165
|
+
try { child.kill() } catch {}
|
|
166
|
+
log(chalk.yellow(`[mcp] Server timed out during pre-test (5s)`))
|
|
167
|
+
resolve(false)
|
|
168
|
+
}, 5000)
|
|
169
|
+
|
|
170
|
+
const child = spawn(serverConfig.command, serverConfig.args, {
|
|
171
|
+
env: { ...process.env, ...serverConfig.env },
|
|
172
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
child.on('error', (err) => {
|
|
176
|
+
clearTimeout(timeout)
|
|
177
|
+
log(chalk.yellow(`[mcp] Server failed to start: ${err.message}`))
|
|
178
|
+
resolve(false)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// MCP server should print to stderr on startup. If it writes anything, it's alive.
|
|
182
|
+
let stderrOutput = ''
|
|
183
|
+
child.stderr.on('data', (chunk) => {
|
|
184
|
+
stderrOutput += chunk.toString()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Give it 2s to start, then check if it's still alive
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
if (child.exitCode !== null) {
|
|
190
|
+
// Already exited = crashed
|
|
191
|
+
clearTimeout(timeout)
|
|
192
|
+
log(chalk.yellow(`[mcp] Server exited with code ${child.exitCode}`))
|
|
193
|
+
if (stderrOutput) log(chalk.dim(`[mcp] stderr: ${stderrOutput.trim().slice(0, 200)}`))
|
|
194
|
+
resolve(false)
|
|
195
|
+
} else {
|
|
196
|
+
// Still running = good
|
|
197
|
+
clearTimeout(timeout)
|
|
198
|
+
try { child.kill() } catch {}
|
|
199
|
+
log(chalk.green(`[mcp] Server pre-test passed`))
|
|
200
|
+
resolve(true)
|
|
201
|
+
}
|
|
202
|
+
}, 2000)
|
|
203
|
+
})
|
|
204
|
+
} catch (err) {
|
|
205
|
+
log(chalk.yellow(`[mcp] Pre-test error: ${err.message}`))
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Manager WebSocket ──────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
// hostId and managerUrl are set here for direct connect mode.
|
|
213
|
+
// In onboarding mode, they are overridden in startup() after login.
|
|
214
|
+
let hostId = argv.agent || 'pending' // Convention: local agent hostId = agentHandle
|
|
215
|
+
const managerUrl = new URL(argv.manager)
|
|
216
|
+
if (argv.agent) {
|
|
217
|
+
managerUrl.searchParams.set('role', 'host')
|
|
218
|
+
managerUrl.searchParams.set('hostId', hostId)
|
|
219
|
+
if (argv.token) {
|
|
220
|
+
managerUrl.searchParams.set('token', argv.token)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let wsRef = null
|
|
225
|
+
let reconnectAttempts = 0
|
|
226
|
+
const MAX_RECONNECT_DELAY_MS = 30_000
|
|
227
|
+
const workspace = path.resolve(argv.workspace)
|
|
228
|
+
const model = argv.model || providerConfig.defaultModel
|
|
229
|
+
|
|
230
|
+
// ── Session tracking ───────────────────────────────────────────────────────
|
|
231
|
+
// Map VO sessionId → Claude session_id for conversation continuity.
|
|
232
|
+
// Cleared on clock-in to avoid stale sessions that ignore --append-system-prompt.
|
|
233
|
+
const SESSION_MAP_FILE = path.join(os.tmpdir(), `vo-sessions-${(argv.agent || 'pending').replace(/\./g, '-')}.json`)
|
|
234
|
+
const sessionMap = new Map()
|
|
235
|
+
|
|
236
|
+
// Clear stale sessions on startup — stale sessions from previous clock-ins
|
|
237
|
+
// ignore new --append-system-prompt and MCP tools.
|
|
238
|
+
try {
|
|
239
|
+
require('fs').unlinkSync(SESSION_MAP_FILE)
|
|
240
|
+
// Will be re-created when first session is mapped
|
|
241
|
+
} catch { /* no file to delete */ }
|
|
242
|
+
|
|
243
|
+
// Track active command processes PER SESSION for concurrent conversation support.
|
|
244
|
+
// Key: sessionId, Value: { child, commandId }. Different clients (web, Telegram)
|
|
245
|
+
// use different sessionIds and can run in parallel without killing each other.
|
|
246
|
+
const activeChildren = new Map()
|
|
247
|
+
|
|
248
|
+
// ── System Prompt & MCP (First-Class Citizen) ──────────────────────────────
|
|
249
|
+
// Built on connect, cached for the lifetime of the connection.
|
|
250
|
+
let cachedSystemPrompt = null
|
|
251
|
+
let mcpConfigPath = null
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Build the system prompt from Registry metadata (same as cloud host adapters).
|
|
255
|
+
* This gives the local agent its identity, office context, and tool manuals.
|
|
256
|
+
*/
|
|
257
|
+
async function buildAgentSystemPrompt() {
|
|
258
|
+
try {
|
|
259
|
+
// Dynamic import — the prompt module is ESM
|
|
260
|
+
const { buildSystemPrompt } = await import('../prompt/index.mjs')
|
|
261
|
+
const agentHandle = argv.agent
|
|
262
|
+
const officeId = agentHandle.split('.').slice(1).join('.')
|
|
263
|
+
|
|
264
|
+
const prompt = await buildSystemPrompt({
|
|
265
|
+
agentHandle,
|
|
266
|
+
officeId,
|
|
267
|
+
workspaceRoot: workspace,
|
|
268
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
if (prompt && prompt.length > 100) {
|
|
272
|
+
log(chalk.green(`System prompt built (${prompt.length} chars)`))
|
|
273
|
+
return prompt
|
|
274
|
+
}
|
|
275
|
+
log(chalk.yellow('System prompt too short or empty, using default'))
|
|
276
|
+
return null
|
|
277
|
+
} catch (err) {
|
|
278
|
+
log(chalk.yellow(`Failed to build system prompt: ${err.message}`))
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Register VO MCP server with Claude Code using `claude mcp add`.
|
|
285
|
+
* This is the correct way per https://code.claude.com/docs/en/mcp
|
|
286
|
+
* The server is registered locally and `claude -p` auto-loads it.
|
|
287
|
+
*/
|
|
288
|
+
async function registerMcpServer() {
|
|
289
|
+
try {
|
|
290
|
+
const agentHandle = argv.agent
|
|
291
|
+
const officeId = agentHandle.split('.').slice(1).join('.')
|
|
292
|
+
const mcpServerPath = path.resolve(__dirname, '../mcp-server/skyoffice-mcp-server.js')
|
|
293
|
+
|
|
294
|
+
const chatBridgeUrl = process.env.CHAT_BRIDGE_URL ||
|
|
295
|
+
process.env.CHAT_BRIDGE_BASE_URL ||
|
|
296
|
+
'https://chatbridge.aladdinagi.xyz'
|
|
297
|
+
|
|
298
|
+
// Use agent-specific MCP name to avoid conflicts when multiple agents run
|
|
299
|
+
const mcpName = `vo-${agentHandle.split('.')[0]}`
|
|
300
|
+
|
|
301
|
+
// Remove existing (idempotent)
|
|
302
|
+
try {
|
|
303
|
+
execSync(`claude mcp remove ${mcpName}`, { stdio: 'ignore', timeout: 5000 })
|
|
304
|
+
} catch { /* ignore — might not exist */ }
|
|
305
|
+
|
|
306
|
+
// Register using `claude mcp add-json --scope user` — the only scope that works
|
|
307
|
+
// with `claude -p` headless mode. Per-agent name avoids conflicts.
|
|
308
|
+
const serverConfig = JSON.stringify({
|
|
309
|
+
type: 'stdio',
|
|
310
|
+
command: 'node',
|
|
311
|
+
args: [mcpServerPath],
|
|
312
|
+
env: {
|
|
313
|
+
CHAT_BRIDGE_URL: chatBridgeUrl,
|
|
314
|
+
CANONICAL_AGENT_HANDLE: agentHandle,
|
|
315
|
+
REGISTRY_OFFICE_ID: officeId,
|
|
316
|
+
WORKSPACE_ROOT: workspace,
|
|
317
|
+
},
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
execSync(`claude mcp add-json ${mcpName} '${serverConfig.replace(/'/g, "'\\''")}' --scope user`, {
|
|
321
|
+
stdio: 'pipe',
|
|
322
|
+
timeout: 10000,
|
|
323
|
+
})
|
|
324
|
+
log(chalk.green(`MCP server '${mcpName}' registered (--scope user)`))
|
|
325
|
+
return mcpName
|
|
326
|
+
} catch (err) {
|
|
327
|
+
log(chalk.yellow(`Failed to register MCP server: ${err.message}`))
|
|
328
|
+
return false
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Unregister VO MCP server on clock-out.
|
|
334
|
+
*/
|
|
335
|
+
function unregisterMcpServer() {
|
|
336
|
+
try {
|
|
337
|
+
const mcpName = `vo-${argv.agent.split('.')[0]}`
|
|
338
|
+
execSync(`claude mcp remove ${mcpName}`, { stdio: 'ignore', timeout: 5000 })
|
|
339
|
+
log(chalk.dim(`MCP server '${mcpName}' unregistered`))
|
|
340
|
+
} catch { /* ignore */ }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Message handling ───────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
function sendJSON(payload) {
|
|
346
|
+
if (wsRef && wsRef.readyState === WebSocket.OPEN) {
|
|
347
|
+
wsRef.send(JSON.stringify(payload))
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function truncateText(value, max = 1200) {
|
|
352
|
+
if (typeof value !== 'string') return value
|
|
353
|
+
if (value.length <= max) return value
|
|
354
|
+
return `${value.slice(0, max)}… [truncated ${value.length - max} chars]`
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function compactToolValue(value) {
|
|
358
|
+
if (value == null) return value
|
|
359
|
+
if (typeof value === 'string') return truncateText(value, 1200)
|
|
360
|
+
try {
|
|
361
|
+
return truncateText(JSON.stringify(value), 1200)
|
|
362
|
+
} catch {
|
|
363
|
+
return truncateText(String(value), 1200)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function sendHostMeta(ws) {
|
|
368
|
+
const meta = {
|
|
369
|
+
type: 'hostMeta',
|
|
370
|
+
hostId,
|
|
371
|
+
timestamp: new Date().toISOString(),
|
|
372
|
+
workspace,
|
|
373
|
+
features: {
|
|
374
|
+
localHost: true,
|
|
375
|
+
provider: argv.provider,
|
|
376
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
377
|
+
streaming: true,
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
ws.send(JSON.stringify(meta))
|
|
381
|
+
log(chalk.green('Sent hostMeta'))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Handle an incoming message from manager-service.
|
|
386
|
+
* For command/userMessage: spawn `claude -p` with stream-json output,
|
|
387
|
+
* parse the NDJSON stream, and emit streaming events back to the manager.
|
|
388
|
+
*/
|
|
389
|
+
async function handleMessage(message) {
|
|
390
|
+
const { type } = message
|
|
391
|
+
|
|
392
|
+
if (type === 'command' || type === 'userMessage') {
|
|
393
|
+
// Normalize
|
|
394
|
+
const text = message.command || message.content || ''
|
|
395
|
+
if (!text) return
|
|
396
|
+
|
|
397
|
+
const sessionId = message.sessionId || null
|
|
398
|
+
const commandId = message.commandId || message.messageId || `cmd-${Date.now()}`
|
|
399
|
+
|
|
400
|
+
const sessionLabel = sessionId ? sessionId.split('--')[1]?.slice(0, 15) || sessionId.slice(0, 20) : 'default'
|
|
401
|
+
log(chalk.cyan(`→ [${sessionLabel}] ${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`))
|
|
402
|
+
|
|
403
|
+
// Kill previous command for THIS SESSION only. Other sessions continue in parallel.
|
|
404
|
+
const prev = sessionId ? activeChildren.get(sessionId) : null
|
|
405
|
+
if (prev?.child) {
|
|
406
|
+
log(chalk.dim(`[${sessionLabel}] Killing previous command for same session`))
|
|
407
|
+
sendJSON({
|
|
408
|
+
type: 'streaming.aborted',
|
|
409
|
+
sessionId,
|
|
410
|
+
commandId: prev.commandId,
|
|
411
|
+
reason: 'replaced-by-new-message',
|
|
412
|
+
abortedAt: new Date().toISOString(),
|
|
413
|
+
})
|
|
414
|
+
try { prev.child.kill('SIGTERM') } catch { /* ignore */ }
|
|
415
|
+
activeChildren.delete(sessionId)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Build CLI args
|
|
419
|
+
const cmd = argv['cli-command'] || providerConfig.command
|
|
420
|
+
const args = [...providerConfig.baseArgs]
|
|
421
|
+
if (model && providerConfig.modelFlag) {
|
|
422
|
+
args.push(providerConfig.modelFlag, model)
|
|
423
|
+
}
|
|
424
|
+
// Resume conversation if we have a Claude session_id for this VO session
|
|
425
|
+
const claudeSessionId = sessionId ? sessionMap.get(sessionId) : null
|
|
426
|
+
if (claudeSessionId && providerConfig.resumeFlag) {
|
|
427
|
+
args.push(providerConfig.resumeFlag, claudeSessionId)
|
|
428
|
+
}
|
|
429
|
+
// System prompt + platform context injection.
|
|
430
|
+
// shell: false — no escaping needed, args passed directly.
|
|
431
|
+
let systemPromptTmpFile = null
|
|
432
|
+
const platformInfo = message.platformInfo || null
|
|
433
|
+
let promptToInject = cachedSystemPrompt || ''
|
|
434
|
+
|
|
435
|
+
// Append platform context so agent knows the message source (Telegram/Slack/Web)
|
|
436
|
+
if (platformInfo?.clientType) {
|
|
437
|
+
const clientLabel = platformInfo.clientType.replace(/-/g, ' ')
|
|
438
|
+
promptToInject += `\n\n## Current Platform\nYou are responding via ${clientLabel}.`
|
|
439
|
+
if (platformInfo.chatId) promptToInject += ` Chat ID: ${platformInfo.chatId}.`
|
|
440
|
+
if (platformInfo.platformContext) promptToInject += `\n${platformInfo.platformContext}`
|
|
441
|
+
log(chalk.dim(`[platform] ${platformInfo.clientType}${platformInfo.chatId ? ` (chat: ${platformInfo.chatId})` : ''}`))
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (promptToInject) {
|
|
445
|
+
args.push('--append-system-prompt', promptToInject)
|
|
446
|
+
}
|
|
447
|
+
// MCP: no --mcp-config flag needed. The VO MCP server is registered via
|
|
448
|
+
// `claude mcp add` during clock-in, so `claude -p` auto-loads it.
|
|
449
|
+
// See: https://code.claude.com/docs/en/mcp
|
|
450
|
+
// User message as last positional argument (raw, no escaping needed with shell: false)
|
|
451
|
+
args.push(text)
|
|
452
|
+
|
|
453
|
+
log(chalk.blue(`Running: ${cmd} ${args.slice(0, 5).join(' ')}... [${args.length} args]`))
|
|
454
|
+
|
|
455
|
+
// 1. Send streaming.started
|
|
456
|
+
sendJSON({
|
|
457
|
+
type: 'streaming.started',
|
|
458
|
+
sessionId,
|
|
459
|
+
commandId,
|
|
460
|
+
status: 'running',
|
|
461
|
+
startedAt: new Date().toISOString(),
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
// 2. Spawn the CLI process
|
|
465
|
+
// shell: false — args are passed directly to the process as an array,
|
|
466
|
+
// avoiding ALL shell interpretation issues. The command is resolved via
|
|
467
|
+
// PATH by Node's child_process (works for npm global bins).
|
|
468
|
+
let child
|
|
469
|
+
try {
|
|
470
|
+
child = spawn(cmd, args, {
|
|
471
|
+
cwd: workspace,
|
|
472
|
+
env: { ...process.env },
|
|
473
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
474
|
+
shell: false,
|
|
475
|
+
})
|
|
476
|
+
if (sessionId) activeChildren.set(sessionId, { child, commandId })
|
|
477
|
+
} catch (err) {
|
|
478
|
+
log(chalk.red(`Failed to spawn CLI: ${err.message}`))
|
|
479
|
+
sendJSON({ type: 'result', sessionId, commandId, stdout: '', stderr: `Failed to start ${argv.provider}: ${err.message}`, exitCode: 1 })
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 3. Parse NDJSON stream from stdout
|
|
484
|
+
const rl = createInterface({ input: child.stdout })
|
|
485
|
+
let fullText = ''
|
|
486
|
+
let resultSessionId = null
|
|
487
|
+
const activeToolsByIndex = new Map()
|
|
488
|
+
const activeToolsById = new Map()
|
|
489
|
+
const finalizedToolIds = new Set()
|
|
490
|
+
const completedToolActions = []
|
|
491
|
+
const activeThinkingByIndex = new Map()
|
|
492
|
+
const completedThinkingBlocks = []
|
|
493
|
+
|
|
494
|
+
const emitToolStart = ({ toolUseId, toolName, input, timestamp }) => {
|
|
495
|
+
const normalizedId = toolUseId || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
496
|
+
const startedAt = Number.isFinite(timestamp) ? timestamp : Date.now()
|
|
497
|
+
activeToolsById.set(normalizedId, { toolName: toolName || 'tool', input, startedAt })
|
|
498
|
+
sendJSON({
|
|
499
|
+
type: 'tool_event',
|
|
500
|
+
sessionId,
|
|
501
|
+
commandId,
|
|
502
|
+
event: {
|
|
503
|
+
eventType: 'tool_start',
|
|
504
|
+
toolName: toolName || 'tool',
|
|
505
|
+
toolUseId: normalizedId,
|
|
506
|
+
input,
|
|
507
|
+
status: 'running',
|
|
508
|
+
timestamp: startedAt,
|
|
509
|
+
},
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const emitToolEnd = ({ toolUseId, toolName, input, result, error, timestamp, force = false }) => {
|
|
514
|
+
if (!toolUseId) return
|
|
515
|
+
if (finalizedToolIds.has(toolUseId) && !force) return
|
|
516
|
+
finalizedToolIds.add(toolUseId)
|
|
517
|
+
const endedAt = Number.isFinite(timestamp) ? timestamp : Date.now()
|
|
518
|
+
const resolvedTool = toolName || activeToolsById.get(toolUseId)?.toolName || 'tool'
|
|
519
|
+
const resolvedInput = input ?? activeToolsById.get(toolUseId)?.input
|
|
520
|
+
const status = error ? 'error' : 'completed'
|
|
521
|
+
const compactResult = compactToolValue(result)
|
|
522
|
+
const compactError = compactToolValue(error)
|
|
523
|
+
sendJSON({
|
|
524
|
+
type: 'tool_event',
|
|
525
|
+
sessionId,
|
|
526
|
+
commandId,
|
|
527
|
+
event: {
|
|
528
|
+
eventType: 'tool_end',
|
|
529
|
+
toolName: resolvedTool,
|
|
530
|
+
toolUseId,
|
|
531
|
+
input: resolvedInput,
|
|
532
|
+
result: compactResult,
|
|
533
|
+
error: compactError,
|
|
534
|
+
status,
|
|
535
|
+
timestamp: endedAt,
|
|
536
|
+
},
|
|
537
|
+
})
|
|
538
|
+
const actionRecord = {
|
|
539
|
+
id: toolUseId,
|
|
540
|
+
toolName: resolvedTool,
|
|
541
|
+
status,
|
|
542
|
+
input: resolvedInput,
|
|
543
|
+
result: compactResult,
|
|
544
|
+
error: compactError,
|
|
545
|
+
timestamp: endedAt,
|
|
546
|
+
}
|
|
547
|
+
const existingIndex = completedToolActions.findIndex((entry) => entry.id === toolUseId)
|
|
548
|
+
if (existingIndex >= 0) completedToolActions[existingIndex] = actionRecord
|
|
549
|
+
else completedToolActions.push(actionRecord)
|
|
550
|
+
activeToolsById.delete(toolUseId)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
rl.on('line', (line) => {
|
|
554
|
+
if (!line.trim()) return
|
|
555
|
+
try {
|
|
556
|
+
const event = JSON.parse(line)
|
|
557
|
+
const streamEvent = event?.type === 'stream_event' ? event.event : null
|
|
558
|
+
const blockIndexRaw = streamEvent?.index ?? streamEvent?.content_block_index
|
|
559
|
+
const blockIndex = Number.isInteger(blockIndexRaw) ? blockIndexRaw : null
|
|
560
|
+
const now = Date.now()
|
|
561
|
+
|
|
562
|
+
// Stream event with text delta → send streaming.delta
|
|
563
|
+
if (streamEvent?.delta?.type === 'text_delta') {
|
|
564
|
+
const chunk = streamEvent.delta.text || ''
|
|
565
|
+
if (chunk) {
|
|
566
|
+
fullText += chunk
|
|
567
|
+
sendJSON({ type: 'streaming.delta', sessionId, commandId, chunk })
|
|
568
|
+
process.stdout.write(chunk) // local echo
|
|
569
|
+
}
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Thinking deltas
|
|
574
|
+
if (streamEvent?.type === 'content_block_delta' && streamEvent?.delta?.type === 'thinking_delta') {
|
|
575
|
+
const deltaText = streamEvent.delta.thinking || streamEvent.delta.text || ''
|
|
576
|
+
if (!deltaText) return
|
|
577
|
+
const thinkingState = blockIndex !== null ? activeThinkingByIndex.get(blockIndex) : null
|
|
578
|
+
const thinkingId = thinkingState?.id || `thinking-${now}`
|
|
579
|
+
if (thinkingState) {
|
|
580
|
+
thinkingState.text = `${thinkingState.text || ''}${deltaText}`
|
|
581
|
+
} else if (blockIndex !== null) {
|
|
582
|
+
activeThinkingByIndex.set(blockIndex, {
|
|
583
|
+
id: thinkingId,
|
|
584
|
+
text: deltaText,
|
|
585
|
+
startedAt: now,
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
sendJSON({
|
|
589
|
+
type: 'thinking_event',
|
|
590
|
+
sessionId,
|
|
591
|
+
commandId,
|
|
592
|
+
event: {
|
|
593
|
+
eventType: 'thinking_delta',
|
|
594
|
+
thinkingId,
|
|
595
|
+
text: deltaText,
|
|
596
|
+
timestamp: now,
|
|
597
|
+
},
|
|
598
|
+
})
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Tool / thinking block starts
|
|
603
|
+
if (streamEvent?.type === 'content_block_start') {
|
|
604
|
+
const block = streamEvent.content_block
|
|
605
|
+
if (block?.type === 'thinking' || block?.type === 'redacted_thinking') {
|
|
606
|
+
const thinkingId = block.id || `thinking-${now}-${Math.random().toString(36).slice(2, 8)}`
|
|
607
|
+
if (blockIndex !== null) {
|
|
608
|
+
activeThinkingByIndex.set(blockIndex, {
|
|
609
|
+
id: thinkingId,
|
|
610
|
+
text: '',
|
|
611
|
+
startedAt: now,
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
sendJSON({
|
|
615
|
+
type: 'thinking_event',
|
|
616
|
+
sessionId,
|
|
617
|
+
commandId,
|
|
618
|
+
event: {
|
|
619
|
+
eventType: 'thinking_start',
|
|
620
|
+
thinkingId,
|
|
621
|
+
timestamp: now,
|
|
622
|
+
},
|
|
623
|
+
})
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (block?.type === 'tool_use') {
|
|
628
|
+
const toolUseId = block.id || `tool-${now}-${Math.random().toString(36).slice(2, 8)}`
|
|
629
|
+
if (blockIndex !== null) {
|
|
630
|
+
activeToolsByIndex.set(blockIndex, {
|
|
631
|
+
toolUseId,
|
|
632
|
+
toolName: block.name || 'tool',
|
|
633
|
+
input: block.input,
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
emitToolStart({
|
|
637
|
+
toolUseId,
|
|
638
|
+
toolName: block.name || 'tool',
|
|
639
|
+
input: block.input,
|
|
640
|
+
timestamp: now,
|
|
641
|
+
})
|
|
642
|
+
}
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Tool / thinking block stops
|
|
647
|
+
if (streamEvent?.type === 'content_block_stop') {
|
|
648
|
+
if (blockIndex !== null) {
|
|
649
|
+
const thinkingState = activeThinkingByIndex.get(blockIndex)
|
|
650
|
+
if (thinkingState) {
|
|
651
|
+
const elapsedMs = Math.max(0, now - (thinkingState.startedAt || now))
|
|
652
|
+
sendJSON({
|
|
653
|
+
type: 'thinking_event',
|
|
654
|
+
sessionId,
|
|
655
|
+
commandId,
|
|
656
|
+
event: {
|
|
657
|
+
eventType: 'thinking_end',
|
|
658
|
+
thinkingId: thinkingState.id,
|
|
659
|
+
timestamp: now,
|
|
660
|
+
elapsedMs,
|
|
661
|
+
},
|
|
662
|
+
})
|
|
663
|
+
completedThinkingBlocks.push({
|
|
664
|
+
id: thinkingState.id,
|
|
665
|
+
text: thinkingState.text || '',
|
|
666
|
+
elapsedMs,
|
|
667
|
+
})
|
|
668
|
+
activeThinkingByIndex.delete(blockIndex)
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const toolState = activeToolsByIndex.get(blockIndex)
|
|
673
|
+
if (toolState?.toolUseId) {
|
|
674
|
+
emitToolEnd({
|
|
675
|
+
toolUseId: toolState.toolUseId,
|
|
676
|
+
toolName: toolState.toolName,
|
|
677
|
+
input: toolState.input,
|
|
678
|
+
timestamp: now,
|
|
679
|
+
})
|
|
680
|
+
activeToolsByIndex.delete(blockIndex)
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Final result message (type=result from claude -p --output-format stream-json)
|
|
688
|
+
if (event.type === 'result') {
|
|
689
|
+
resultSessionId = event.session_id || null
|
|
690
|
+
if (event.result && !fullText) {
|
|
691
|
+
fullText = event.result
|
|
692
|
+
}
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Message event with assistant content
|
|
697
|
+
if (event.type === 'message' && event.message?.role === 'assistant') {
|
|
698
|
+
// Extract text from content blocks
|
|
699
|
+
const content = event.message.content || []
|
|
700
|
+
for (const block of content) {
|
|
701
|
+
if (block.type === 'text' && block.text) {
|
|
702
|
+
if (!fullText) fullText = block.text
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Tool result blocks appear in user role messages in Claude stream-json.
|
|
709
|
+
// Use them to close tool lifecycles with concrete result payloads.
|
|
710
|
+
if (event.type === 'message' && event.message?.role === 'user') {
|
|
711
|
+
const content = event.message.content || []
|
|
712
|
+
for (const block of content) {
|
|
713
|
+
if (block?.type !== 'tool_result') continue
|
|
714
|
+
const toolUseId = block.tool_use_id || block.toolUseId
|
|
715
|
+
let resultPayload = block.content
|
|
716
|
+
if (Array.isArray(resultPayload)) {
|
|
717
|
+
resultPayload = resultPayload
|
|
718
|
+
.map((item) => (typeof item?.text === 'string' ? item.text : typeof item === 'string' ? item : JSON.stringify(item)))
|
|
719
|
+
.join('\n')
|
|
720
|
+
}
|
|
721
|
+
if (resultPayload && typeof resultPayload !== 'string') {
|
|
722
|
+
try {
|
|
723
|
+
resultPayload = JSON.stringify(resultPayload)
|
|
724
|
+
} catch {
|
|
725
|
+
resultPayload = String(resultPayload)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
emitToolEnd({
|
|
729
|
+
toolUseId,
|
|
730
|
+
result: resultPayload || '',
|
|
731
|
+
timestamp: now,
|
|
732
|
+
force: true,
|
|
733
|
+
})
|
|
734
|
+
}
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
} catch {
|
|
738
|
+
// Not JSON or unrecognized format — ignore
|
|
739
|
+
}
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
// stderr → log
|
|
743
|
+
child.stderr.on('data', (chunk) => {
|
|
744
|
+
const text = chunk.toString()
|
|
745
|
+
if (text.trim()) {
|
|
746
|
+
log(chalk.dim(`[stderr] ${text.trim().slice(0, 200)}`))
|
|
747
|
+
}
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
// 4. On process exit, send completion events
|
|
751
|
+
child.on('close', (code) => {
|
|
752
|
+
if (sessionId && activeChildren.get(sessionId)?.child === child) activeChildren.delete(sessionId)
|
|
753
|
+
|
|
754
|
+
// Clean up system prompt temp file
|
|
755
|
+
if (systemPromptTmpFile) {
|
|
756
|
+
try { require('fs').unlinkSync(systemPromptTmpFile) } catch { /* ignore */ }
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Store Claude session_id for conversation continuity
|
|
760
|
+
if (resultSessionId && sessionId) {
|
|
761
|
+
sessionMap.set(sessionId, resultSessionId)
|
|
762
|
+
log(chalk.dim(`Session mapped: ${sessionId} → ${resultSessionId}`))
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (fullText) {
|
|
766
|
+
process.stdout.write('\n')
|
|
767
|
+
log(chalk.green(`← ${fullText.slice(0, 80)}${fullText.length > 80 ? '...' : ''}`))
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Send streaming.completed
|
|
771
|
+
sendJSON({
|
|
772
|
+
type: 'streaming.completed',
|
|
773
|
+
sessionId,
|
|
774
|
+
commandId,
|
|
775
|
+
status: 'completed',
|
|
776
|
+
completedAt: new Date().toISOString(),
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
// Send final result
|
|
780
|
+
const contentBlocks = []
|
|
781
|
+
if (completedThinkingBlocks.length > 0) {
|
|
782
|
+
for (const block of completedThinkingBlocks) {
|
|
783
|
+
contentBlocks.push({
|
|
784
|
+
type: 'thinking',
|
|
785
|
+
id: block.id,
|
|
786
|
+
text: block.text || '',
|
|
787
|
+
elapsedMs: typeof block.elapsedMs === 'number' ? block.elapsedMs : undefined,
|
|
788
|
+
_sortTs: Date.now() - (block.elapsedMs || 0),
|
|
789
|
+
})
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (completedToolActions.length > 0) {
|
|
793
|
+
for (const action of completedToolActions) {
|
|
794
|
+
contentBlocks.push({
|
|
795
|
+
type: 'tool',
|
|
796
|
+
id: action.id,
|
|
797
|
+
name: action.toolName,
|
|
798
|
+
status: action.status,
|
|
799
|
+
input: action.input,
|
|
800
|
+
result: action.result,
|
|
801
|
+
error: action.error,
|
|
802
|
+
_sortTs: action.timestamp || Date.now(),
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (fullText && fullText.trim()) {
|
|
807
|
+
contentBlocks.push({
|
|
808
|
+
type: 'text',
|
|
809
|
+
id: `text-${Date.now()}`,
|
|
810
|
+
text: fullText.trim(),
|
|
811
|
+
_sortTs: Date.now(),
|
|
812
|
+
})
|
|
813
|
+
}
|
|
814
|
+
contentBlocks.sort((a, b) => (a._sortTs || 0) - (b._sortTs || 0))
|
|
815
|
+
const normalizedContentBlocks = contentBlocks.map(({ _sortTs, ...rest }) => rest)
|
|
816
|
+
|
|
817
|
+
try {
|
|
818
|
+
sendJSON({
|
|
819
|
+
type: 'result',
|
|
820
|
+
sessionId,
|
|
821
|
+
commandId,
|
|
822
|
+
stdout: fullText || '(No response)',
|
|
823
|
+
stderr: '',
|
|
824
|
+
exitCode: code || 0,
|
|
825
|
+
metadata: {
|
|
826
|
+
toolActions: completedToolActions.length > 0 ? completedToolActions : undefined,
|
|
827
|
+
thinkingBlocks: completedThinkingBlocks.length > 0 ? completedThinkingBlocks : undefined,
|
|
828
|
+
contentBlocks: normalizedContentBlocks.length > 0 ? normalizedContentBlocks : undefined,
|
|
829
|
+
},
|
|
830
|
+
})
|
|
831
|
+
} catch (err) {
|
|
832
|
+
// Fallback to minimal payload if metadata gets too large for transport.
|
|
833
|
+
sendJSON({
|
|
834
|
+
type: 'result',
|
|
835
|
+
sessionId,
|
|
836
|
+
commandId,
|
|
837
|
+
stdout: fullText || '(No response)',
|
|
838
|
+
stderr: '',
|
|
839
|
+
exitCode: code || 0,
|
|
840
|
+
metadata: {
|
|
841
|
+
thinkingBlocks: completedThinkingBlocks.length > 0 ? completedThinkingBlocks : undefined,
|
|
842
|
+
},
|
|
843
|
+
})
|
|
844
|
+
log(chalk.yellow(`Result metadata fallback used: ${err.message}`))
|
|
845
|
+
}
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
child.on('error', (err) => {
|
|
849
|
+
log(chalk.red(`CLI process error: ${err.message}`))
|
|
850
|
+
if (err.code === 'ENOENT') {
|
|
851
|
+
log(chalk.yellow(`"${cmd}" not found. Install it with: ${providerConfig.installHint}`))
|
|
852
|
+
}
|
|
853
|
+
sendJSON({
|
|
854
|
+
type: 'result',
|
|
855
|
+
sessionId,
|
|
856
|
+
commandId,
|
|
857
|
+
stdout: '',
|
|
858
|
+
stderr: `CLI error: ${err.message}`,
|
|
859
|
+
exitCode: 1,
|
|
860
|
+
metadata: {
|
|
861
|
+
toolActions: completedToolActions.length > 0 ? completedToolActions : undefined,
|
|
862
|
+
thinkingBlocks: completedThinkingBlocks.length > 0 ? completedThinkingBlocks : undefined,
|
|
863
|
+
},
|
|
864
|
+
})
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if (type === 'session-open') {
|
|
871
|
+
log(chalk.green(`Session opened (${message.sessionId || 'unknown'})`))
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (type === 'session-close') {
|
|
876
|
+
log(chalk.blue(`Session closed (${message.sessionId || 'unknown'})`))
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (type === 'session-config') {
|
|
881
|
+
log(chalk.blue('Session config received'))
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
log(chalk.gray(`Ignoring message type: ${type}`))
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// ── WebSocket connection with reconnect ────────────────────────────────────
|
|
889
|
+
|
|
890
|
+
function connect() {
|
|
891
|
+
log(chalk.blue(`Connecting to ${managerUrl.href}`))
|
|
892
|
+
const ws = new WebSocket(managerUrl.href)
|
|
893
|
+
wsRef = ws
|
|
894
|
+
|
|
895
|
+
const pingMs = 10_000
|
|
896
|
+
let pingTimer = null
|
|
897
|
+
|
|
898
|
+
ws.on('open', async () => {
|
|
899
|
+
log(chalk.green('Connected to Virtual Office'))
|
|
900
|
+
reconnectAttempts = 0
|
|
901
|
+
|
|
902
|
+
// Keepalive pings
|
|
903
|
+
pingTimer = setInterval(() => {
|
|
904
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
905
|
+
try { ws.ping() } catch { /* ignore */ }
|
|
906
|
+
}
|
|
907
|
+
}, pingMs)
|
|
908
|
+
|
|
909
|
+
sendHostMeta(ws)
|
|
910
|
+
|
|
911
|
+
// Build system prompt and register MCP server (first-class citizen setup)
|
|
912
|
+
log(chalk.blue('Setting up agent identity and tools...'))
|
|
913
|
+
if (!cachedSystemPrompt) {
|
|
914
|
+
cachedSystemPrompt = await buildAgentSystemPrompt()
|
|
915
|
+
}
|
|
916
|
+
// Register MCP server via `claude mcp add` (the official way)
|
|
917
|
+
// This makes VO tools auto-available in all `claude -p` invocations
|
|
918
|
+
let mcpRegistered = false
|
|
919
|
+
if (!mcpConfigPath) {
|
|
920
|
+
mcpRegistered = await registerMcpServer()
|
|
921
|
+
if (mcpRegistered) mcpConfigPath = 'registered' // flag to skip re-registration
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Banner
|
|
925
|
+
console.log('')
|
|
926
|
+
console.log(chalk.bold.cyan(' ╔══════════════════════════════════════╗'))
|
|
927
|
+
console.log(chalk.bold.cyan(' ║ Clocked in to Virtual Office ║'))
|
|
928
|
+
console.log(chalk.bold.cyan(' ╚══════════════════════════════════════╝'))
|
|
929
|
+
console.log(chalk.dim(` Agent: ${argv.agent}`))
|
|
930
|
+
console.log(chalk.dim(` Provider: ${argv.provider}`))
|
|
931
|
+
console.log(chalk.dim(` Workspace: ${workspace}`))
|
|
932
|
+
console.log(chalk.dim(` Identity: ${cachedSystemPrompt ? 'loaded' : 'default'}`))
|
|
933
|
+
console.log(chalk.dim(` Tools: ${mcpRegistered ? 'VO MCP registered ✓' : 'basic only'}`))
|
|
934
|
+
console.log(chalk.dim(` Press Ctrl+C to clock out`))
|
|
935
|
+
console.log('')
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
ws.on('message', (data) => {
|
|
939
|
+
let message
|
|
940
|
+
try {
|
|
941
|
+
message = JSON.parse(data.toString())
|
|
942
|
+
} catch (err) {
|
|
943
|
+
log(chalk.red(`Bad message: ${err.message}`))
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
handleMessage(message)
|
|
947
|
+
})
|
|
948
|
+
|
|
949
|
+
ws.on('close', (code, reason) => {
|
|
950
|
+
const reasonStr = reason?.toString() || ''
|
|
951
|
+
log(chalk.red(`Disconnected (${code} ${reasonStr})`))
|
|
952
|
+
wsRef = null
|
|
953
|
+
if (pingTimer) clearInterval(pingTimer)
|
|
954
|
+
|
|
955
|
+
// Kill all active CLI processes on disconnect
|
|
956
|
+
for (const [, entry] of activeChildren) {
|
|
957
|
+
try { entry?.child?.kill('SIGTERM') } catch { /* ignore */ }
|
|
958
|
+
}
|
|
959
|
+
activeChildren.clear()
|
|
960
|
+
|
|
961
|
+
// Auth rejection — don't retry, token is invalid or missing
|
|
962
|
+
if (code === 1008) {
|
|
963
|
+
console.log('')
|
|
964
|
+
console.log(chalk.red.bold(' Connection rejected: invalid or expired token.'))
|
|
965
|
+
console.log(chalk.yellow(' Please re-invite the agent from the Virtual Office UI'))
|
|
966
|
+
console.log(chalk.yellow(' to generate a fresh --token.'))
|
|
967
|
+
console.log('')
|
|
968
|
+
process.exit(1)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
scheduleReconnect()
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
ws.on('error', (err) => {
|
|
975
|
+
log(chalk.red(`WebSocket error: ${err.message}`))
|
|
976
|
+
})
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function scheduleReconnect() {
|
|
980
|
+
reconnectAttempts++
|
|
981
|
+
const delay = Math.min(2000 * Math.pow(1.5, reconnectAttempts - 1), MAX_RECONNECT_DELAY_MS)
|
|
982
|
+
log(chalk.yellow(`Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${reconnectAttempts})...`))
|
|
983
|
+
setTimeout(connect, delay)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ── Local Device Connection (file system access for Workspace Panel) ───────
|
|
987
|
+
// Second WebSocket to Chat Bridge /local-agent — registers as a local device
|
|
988
|
+
// so the web UI can browse local files via Workspace Panel.
|
|
989
|
+
// Uses the same protocol as Adam Desktop (tool_request/tool_response).
|
|
990
|
+
|
|
991
|
+
let deviceWsRef = null
|
|
992
|
+
|
|
993
|
+
let deviceReconnectTimer = null
|
|
994
|
+
let devicePingTimer = null
|
|
995
|
+
|
|
996
|
+
function connectLocalDevice() {
|
|
997
|
+
// Clear any pending reconnect to avoid duplicates
|
|
998
|
+
if (deviceReconnectTimer) { clearTimeout(deviceReconnectTimer); deviceReconnectTimer = null }
|
|
999
|
+
|
|
1000
|
+
const chatBridgeWs = (process.env.CHAT_BRIDGE_WS_URL || 'wss://chatbridge.aladdinagi.xyz')
|
|
1001
|
+
.replace(/^http/, 'ws')
|
|
1002
|
+
.replace(/\/+$/, '')
|
|
1003
|
+
const deviceUrl = `${chatBridgeWs}/local-agent`
|
|
1004
|
+
|
|
1005
|
+
log(chalk.blue(`Connecting local device to ${deviceUrl}`))
|
|
1006
|
+
const dws = new WebSocket(deviceUrl)
|
|
1007
|
+
deviceWsRef = dws
|
|
1008
|
+
|
|
1009
|
+
dws.on('open', () => {
|
|
1010
|
+
log(chalk.green('Local device connected'))
|
|
1011
|
+
const agentHandle = argv.agent
|
|
1012
|
+
const officeId = agentHandle.split('.').slice(1).join('.')
|
|
1013
|
+
// Register as agent-bound device (NOT office-wide).
|
|
1014
|
+
// Only this specific agent's sessions can access the local file system.
|
|
1015
|
+
dws.send(JSON.stringify({
|
|
1016
|
+
type: 'register',
|
|
1017
|
+
officeId,
|
|
1018
|
+
agentHandle,
|
|
1019
|
+
userId: agentHandle,
|
|
1020
|
+
workingDirectory: workspace,
|
|
1021
|
+
metadata: {
|
|
1022
|
+
deviceType: 'local-agent',
|
|
1023
|
+
hostname: os.hostname(),
|
|
1024
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
1025
|
+
capabilities: ['file_ops', 'exec_command', 'computer_use'],
|
|
1026
|
+
agentBound: true,
|
|
1027
|
+
boundAgentHandle: agentHandle,
|
|
1028
|
+
officeWide: false,
|
|
1029
|
+
},
|
|
1030
|
+
}))
|
|
1031
|
+
|
|
1032
|
+
// Keepalive ping every 30s to prevent Cloudflare/ALB idle timeout
|
|
1033
|
+
if (devicePingTimer) clearInterval(devicePingTimer)
|
|
1034
|
+
devicePingTimer = setInterval(() => {
|
|
1035
|
+
if (dws.readyState === WebSocket.OPEN) {
|
|
1036
|
+
try { dws.ping() } catch { /* ignore */ }
|
|
1037
|
+
}
|
|
1038
|
+
}, 30_000)
|
|
1039
|
+
})
|
|
1040
|
+
|
|
1041
|
+
dws.on('message', async (data) => {
|
|
1042
|
+
let msg
|
|
1043
|
+
try { msg = JSON.parse(data.toString()) } catch { return }
|
|
1044
|
+
|
|
1045
|
+
// Ignore registration ack and other non-tool messages
|
|
1046
|
+
if (msg.type === 'tool_request') {
|
|
1047
|
+
const { requestId, toolName, params } = msg
|
|
1048
|
+
log(chalk.cyan(`[device] tool_request: ${toolName}`))
|
|
1049
|
+
try {
|
|
1050
|
+
const result = await executeLocalTool(toolName, params || {})
|
|
1051
|
+
dws.send(JSON.stringify({ type: 'tool_response', requestId, result }))
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
dws.send(JSON.stringify({ type: 'tool_response', requestId, error: err.message }))
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
dws.on('close', (code) => {
|
|
1059
|
+
log(chalk.yellow(`Local device disconnected (${code})`))
|
|
1060
|
+
deviceWsRef = null
|
|
1061
|
+
if (devicePingTimer) { clearInterval(devicePingTimer); devicePingTimer = null }
|
|
1062
|
+
// Reconnect after delay (only if not shutting down)
|
|
1063
|
+
if (code !== 1000) {
|
|
1064
|
+
deviceReconnectTimer = setTimeout(connectLocalDevice, 5000)
|
|
1065
|
+
}
|
|
1066
|
+
})
|
|
1067
|
+
|
|
1068
|
+
dws.on('error', (err) => {
|
|
1069
|
+
log(chalk.dim(`[device] WebSocket error: ${err.message}`))
|
|
1070
|
+
})
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Execute a local tool request (file operations, shell commands).
|
|
1075
|
+
* Same capabilities as Adam Desktop's localTools.
|
|
1076
|
+
*/
|
|
1077
|
+
async function executeLocalTool(toolName, params) {
|
|
1078
|
+
const fs = await import('fs/promises')
|
|
1079
|
+
|
|
1080
|
+
switch (toolName) {
|
|
1081
|
+
case 'list_files': {
|
|
1082
|
+
const dirPath = path.resolve(workspace, params.path || '.')
|
|
1083
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
|
1084
|
+
const files = await Promise.all(entries.map(async (entry) => {
|
|
1085
|
+
const fullPath = path.join(dirPath, entry.name)
|
|
1086
|
+
try {
|
|
1087
|
+
const stat = await fs.stat(fullPath)
|
|
1088
|
+
return {
|
|
1089
|
+
name: entry.name,
|
|
1090
|
+
path: fullPath,
|
|
1091
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
1092
|
+
size: stat.size,
|
|
1093
|
+
modifiedAt: stat.mtime.toISOString(),
|
|
1094
|
+
}
|
|
1095
|
+
} catch {
|
|
1096
|
+
return { name: entry.name, path: fullPath, type: entry.isDirectory() ? 'directory' : 'file' }
|
|
1097
|
+
}
|
|
1098
|
+
}))
|
|
1099
|
+
return { files }
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
case 'read_file': {
|
|
1103
|
+
const filePath = path.resolve(workspace, params.path)
|
|
1104
|
+
const content = await fs.readFile(filePath, 'utf-8')
|
|
1105
|
+
return { content, path: filePath }
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
case 'write_file': {
|
|
1109
|
+
const filePath = path.resolve(workspace, params.path)
|
|
1110
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
1111
|
+
await fs.writeFile(filePath, params.content || '', 'utf-8')
|
|
1112
|
+
return { success: true, path: filePath }
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
case 'delete_file': {
|
|
1116
|
+
const filePath = path.resolve(workspace, params.path)
|
|
1117
|
+
await fs.rm(filePath, { recursive: true, force: true })
|
|
1118
|
+
return { success: true, path: filePath }
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
case 'exec_command': {
|
|
1122
|
+
const { execSync } = await import('child_process')
|
|
1123
|
+
const result = execSync(params.command, {
|
|
1124
|
+
cwd: params.cwd || workspace,
|
|
1125
|
+
timeout: params.timeout || 30000,
|
|
1126
|
+
encoding: 'utf-8',
|
|
1127
|
+
maxBuffer: 1024 * 1024,
|
|
1128
|
+
})
|
|
1129
|
+
return { stdout: result, exitCode: 0 }
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
default:
|
|
1133
|
+
throw new Error(`Unknown local tool: ${toolName}`)
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// ── Graceful shutdown ──────────────────────────────────────────────────────
|
|
1138
|
+
|
|
1139
|
+
function shutdown() {
|
|
1140
|
+
console.log('')
|
|
1141
|
+
log(chalk.yellow('Clocking out...'))
|
|
1142
|
+
// Unregister MCP server so it doesn't linger in ~/.claude.json
|
|
1143
|
+
unregisterMcpServer()
|
|
1144
|
+
for (const [, entry] of activeChildren) {
|
|
1145
|
+
try { entry?.child?.kill('SIGTERM') } catch { /* ignore */ }
|
|
1146
|
+
}
|
|
1147
|
+
activeChildren.clear()
|
|
1148
|
+
if (wsRef) {
|
|
1149
|
+
try { wsRef.close(1000, 'shutdown') } catch { /* ignore */ }
|
|
1150
|
+
}
|
|
1151
|
+
if (deviceWsRef) {
|
|
1152
|
+
try { deviceWsRef.close(1000, 'shutdown') } catch { /* ignore */ }
|
|
1153
|
+
}
|
|
1154
|
+
process.exit(0)
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
process.on('SIGINT', shutdown)
|
|
1158
|
+
process.on('SIGTERM', shutdown)
|
|
1159
|
+
|
|
1160
|
+
// ── Startup ────────────────────────────────────────────────────────────────
|
|
1161
|
+
|
|
1162
|
+
async function startup() {
|
|
1163
|
+
// Direct connect mode: --agent + --token provided → skip onboarding
|
|
1164
|
+
if (argv.agent && argv.token) {
|
|
1165
|
+
console.log('')
|
|
1166
|
+
console.log(chalk.bold(' Virtual Office — Local Host Adapter'))
|
|
1167
|
+
console.log(chalk.dim(` Agent: ${argv.agent} | Provider: ${argv.provider}`))
|
|
1168
|
+
console.log('')
|
|
1169
|
+
|
|
1170
|
+
// Check for provider API key (only for non-claude providers)
|
|
1171
|
+
if (providerConfig.envCheck && !process.env[providerConfig.envCheck]) {
|
|
1172
|
+
log(chalk.yellow(`Warning: ${providerConfig.envCheck} not set. ${argv.provider} may fail to start.`))
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
connect()
|
|
1176
|
+
connectLocalDevice()
|
|
1177
|
+
return
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Interactive onboarding mode: login → select office → name agent → clock in
|
|
1181
|
+
try {
|
|
1182
|
+
const { runOnboarding, printClockInBanner } = await import('./onboarding.js')
|
|
1183
|
+
const result = await runOnboarding()
|
|
1184
|
+
|
|
1185
|
+
// Update runtime config with onboarding result
|
|
1186
|
+
argv.agent = result.agent
|
|
1187
|
+
argv.token = result.token
|
|
1188
|
+
hostId = result.agent
|
|
1189
|
+
|
|
1190
|
+
// Rebuild manager URL with new agent/token
|
|
1191
|
+
const newManagerUrl = new URL(argv.manager)
|
|
1192
|
+
newManagerUrl.searchParams.set('role', 'host')
|
|
1193
|
+
newManagerUrl.searchParams.set('hostId', hostId)
|
|
1194
|
+
newManagerUrl.searchParams.set('token', result.token)
|
|
1195
|
+
managerUrl.href = newManagerUrl.href
|
|
1196
|
+
|
|
1197
|
+
printClockInBanner({
|
|
1198
|
+
agentHandle: result.agent,
|
|
1199
|
+
model: `Claude Opus 4.6`,
|
|
1200
|
+
seat: result.seat,
|
|
1201
|
+
workspace: workspace,
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
connect()
|
|
1205
|
+
connectLocalDevice()
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
// If onboarding dependencies aren't installed (e.g., running from source without npm install)
|
|
1208
|
+
// fall back to showing help
|
|
1209
|
+
if (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'MODULE_NOT_FOUND') {
|
|
1210
|
+
console.log('')
|
|
1211
|
+
console.log(chalk.bold(' Virtual Office — Claude Code'))
|
|
1212
|
+
console.log('')
|
|
1213
|
+
console.log(chalk.yellow(' Interactive mode requires additional dependencies.'))
|
|
1214
|
+
console.log(chalk.yellow(' Run: npm install (in manager-host-sdk/local-host/)'))
|
|
1215
|
+
console.log('')
|
|
1216
|
+
console.log(chalk.dim(' Or use direct connect mode:'))
|
|
1217
|
+
console.log(chalk.dim(' npx @office-xyz/claude-code --agent <handle> --token <token>'))
|
|
1218
|
+
console.log('')
|
|
1219
|
+
process.exit(1)
|
|
1220
|
+
}
|
|
1221
|
+
console.error(chalk.red(`Startup error: ${err.message}`))
|
|
1222
|
+
process.exit(1)
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
startup()
|