@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/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()