@office-xyz/claude-code 0.1.0 → 0.1.3

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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to `@office-xyz/claude-code` will be documented in this file.
4
4
 
5
+ ## [0.1.2] - 2026-02-15
6
+
7
+ ### Changed
8
+ - Integrate public repo sync into npm publish workflow
9
+ - Fall back to Cloudflare URL for SSE/WebSocket connections
10
+
11
+ ## [0.1.1] - 2026-02-15
12
+
13
+ ### Fixed
14
+ - Fix crash when running `npx @office-xyz/claude-code` without `--agent` flag (`argv.agent` undefined → `.split()` TypeError)
15
+ - `label` variable now defaults to `'local-host'` in interactive onboarding mode and updates after onboarding completes
16
+
5
17
  ## [0.1.0] - 2026-02-15
6
18
 
7
19
  ### Added
package/index.js CHANGED
@@ -23,6 +23,28 @@ import { execSync } from 'child_process'
23
23
  import path from 'path'
24
24
  import os from 'os'
25
25
  import { fileURLToPath } from 'url'
26
+ // Inline tool name normalizer (can't import from parent CJS package — ESM/CJS conflict)
27
+ // Maps raw CLI tool names to standard frontend TOOL_CONFIG keys
28
+ const TOOL_NAME_MAP = {
29
+ // Codex CLI
30
+ command_execution: 'Bash', file_edit: 'FileEdit', file_read: 'FileRead',
31
+ file_change: 'FileEdit', file_write: 'FileWrite', web_search: 'WebSearch',
32
+ mcp_call: 'Mcp', mcp_tool_call: 'Mcp', unknown_tool: 'default',
33
+ // Gemini CLI
34
+ shell: 'Bash', edit: 'Edit', read: 'Read', write: 'Write',
35
+ search_files: 'Grep', list_files: 'LS', web_fetch: 'WebFetch', google_search: 'WebSearch',
36
+ // Kimi / DeepSeek / Qwen CLI
37
+ execute_command: 'Bash', read_file: 'Read', write_file: 'Write', edit_file: 'Edit',
38
+ search: 'Grep', run_command: 'Bash', code_edit: 'FileEdit', code_search: 'Grep',
39
+ terminal: 'Bash', file_operation: 'FileEdit',
40
+ // General aliases
41
+ bash: 'Bash', grep: 'Grep', glob: 'Glob', ls: 'LS',
42
+ }
43
+ function normalizeToolName(rawName) {
44
+ if (!rawName || typeof rawName !== 'string') return 'default'
45
+ const lower = rawName.toLowerCase()
46
+ return TOOL_NAME_MAP[lower] || TOOL_NAME_MAP[rawName] || rawName
47
+ }
26
48
 
27
49
  const __filename = fileURLToPath(import.meta.url)
28
50
  const __dirname = path.dirname(__filename)
@@ -144,7 +166,7 @@ function shellEscapeArg(s) {
144
166
 
145
167
  // ── Logging ────────────────────────────────────────────────────────────────
146
168
 
147
- const label = argv.agent.split('.')[0] || argv.agent
169
+ let label = argv.agent ? (argv.agent.split('.')[0] || argv.agent) : 'local-host'
148
170
  function log(...args) {
149
171
  console.log(chalk.dim(`[${new Date().toISOString()}][${label}]`), ...args)
150
172
  }
@@ -224,6 +246,8 @@ if (argv.agent) {
224
246
  let wsRef = null
225
247
  let reconnectAttempts = 0
226
248
  const MAX_RECONNECT_DELAY_MS = 30_000
249
+ let registryHeartbeatTimer = null
250
+ const REGISTRY_HEARTBEAT_INTERVAL_MS = 30_000 // Report liveness to Registry every 30s
227
251
  const workspace = path.resolve(argv.workspace)
228
252
  const model = argv.model || providerConfig.defaultModel
229
253
 
@@ -451,6 +475,11 @@ async function handleMessage(message) {
451
475
  args.push(text)
452
476
 
453
477
  log(chalk.blue(`Running: ${cmd} ${args.slice(0, 5).join(' ')}... [${args.length} args]`))
478
+ if (process.env.ANTHROPIC_API_KEY) {
479
+ log(chalk.dim(` Auth: Claude login session (stripped inherited API key from env)`))
480
+ } else {
481
+ log(chalk.dim(` Auth: Claude login session`))
482
+ }
454
483
 
455
484
  // 1. Send streaming.started
456
485
  sendJSON({
@@ -467,9 +496,16 @@ async function handleMessage(message) {
467
496
  // PATH by Node's child_process (works for npm global bins).
468
497
  let child
469
498
  try {
499
+ // Strip ANTHROPIC_API_KEY from child env so Claude Code CLI uses the
500
+ // user's local login session (claude login / Max subscription) instead
501
+ // of consuming API credits from a shared key that may have been loaded
502
+ // by load-aladdin-env.sh or other infra scripts in the same shell.
503
+ const childEnv = { ...process.env }
504
+ delete childEnv.ANTHROPIC_API_KEY
505
+
470
506
  child = spawn(cmd, args, {
471
507
  cwd: workspace,
472
- env: { ...process.env },
508
+ env: childEnv,
473
509
  stdio: ['ignore', 'pipe', 'pipe'],
474
510
  shell: false,
475
511
  })
@@ -494,14 +530,16 @@ async function handleMessage(message) {
494
530
  const emitToolStart = ({ toolUseId, toolName, input, timestamp }) => {
495
531
  const normalizedId = toolUseId || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
496
532
  const startedAt = Number.isFinite(timestamp) ? timestamp : Date.now()
497
- activeToolsById.set(normalizedId, { toolName: toolName || 'tool', input, startedAt })
533
+ // Normalize tool names for consistent frontend display across all CLI providers
534
+ const normalized = normalizeToolName(toolName || 'tool')
535
+ activeToolsById.set(normalizedId, { toolName: normalized, input, startedAt })
498
536
  sendJSON({
499
537
  type: 'tool_event',
500
538
  sessionId,
501
539
  commandId,
502
540
  event: {
503
541
  eventType: 'tool_start',
504
- toolName: toolName || 'tool',
542
+ toolName: normalized,
505
543
  toolUseId: normalizedId,
506
544
  input,
507
545
  status: 'running',
@@ -515,7 +553,8 @@ async function handleMessage(message) {
515
553
  if (finalizedToolIds.has(toolUseId) && !force) return
516
554
  finalizedToolIds.add(toolUseId)
517
555
  const endedAt = Number.isFinite(timestamp) ? timestamp : Date.now()
518
- const resolvedTool = toolName || activeToolsById.get(toolUseId)?.toolName || 'tool'
556
+ const rawTool = toolName || activeToolsById.get(toolUseId)?.toolName || 'tool'
557
+ const resolvedTool = normalizeToolName(rawTool)
519
558
  const resolvedInput = input ?? activeToolsById.get(toolUseId)?.input
520
559
  const status = error ? 'error' : 'completed'
521
560
  const compactResult = compactToolValue(result)
@@ -570,6 +609,29 @@ async function handleMessage(message) {
570
609
  return
571
610
  }
572
611
 
612
+ // Input JSON deltas — accumulate tool input as it streams in
613
+ // Claude CLI sends tool input progressively: content_block_start has input: {},
614
+ // then input_json_delta events carry the actual JSON. Without accumulating these,
615
+ // tool indicators show "(no command)" because input is empty.
616
+ if (streamEvent?.type === 'content_block_delta' && streamEvent?.delta?.type === 'input_json_delta') {
617
+ const partialJson = streamEvent.delta.partial_json || ''
618
+ if (blockIndex !== null) {
619
+ const toolState = activeToolsByIndex.get(blockIndex)
620
+ if (toolState) {
621
+ // Accumulate raw JSON string — we'll parse the complete object later
622
+ if (!toolState._inputJsonBuffer) toolState._inputJsonBuffer = ''
623
+ toolState._inputJsonBuffer += partialJson
624
+ // Try to parse accumulated JSON to update input (best-effort)
625
+ try {
626
+ toolState.input = JSON.parse(toolState._inputJsonBuffer)
627
+ } catch {
628
+ // Not complete JSON yet — that's fine, keep accumulating
629
+ }
630
+ }
631
+ }
632
+ return
633
+ }
634
+
573
635
  // Thinking deltas
574
636
  if (streamEvent?.type === 'content_block_delta' && streamEvent?.delta?.type === 'thinking_delta') {
575
637
  const deltaText = streamEvent.delta.thinking || streamEvent.delta.text || ''
@@ -695,12 +757,35 @@ async function handleMessage(message) {
695
757
 
696
758
  // Message event with assistant content
697
759
  if (event.type === 'message' && event.message?.role === 'assistant') {
698
- // Extract text from content blocks
699
760
  const content = event.message.content || []
700
761
  for (const block of content) {
762
+ // Extract text blocks
701
763
  if (block.type === 'text' && block.text) {
702
764
  if (!fullText) fullText = block.text
703
765
  }
766
+ // Extract complete tool_use input — the assistant message contains the
767
+ // fully accumulated input (unlike content_block_start which has {}).
768
+ // This allows us to retroactively update tool events with real input data.
769
+ if (block.type === 'tool_use' && block.id && block.input) {
770
+ const existing = activeToolsById.get(block.id)
771
+ if (existing && (!existing.input || Object.keys(existing.input).length === 0)) {
772
+ existing.input = block.input
773
+ // Re-emit tool_start with complete input so frontend can display details
774
+ sendJSON({
775
+ type: 'tool_event',
776
+ sessionId,
777
+ commandId,
778
+ event: {
779
+ eventType: 'tool_start',
780
+ toolName: existing.toolName,
781
+ toolUseId: block.id,
782
+ input: block.input,
783
+ status: 'running',
784
+ timestamp: existing.startedAt || now,
785
+ },
786
+ })
787
+ }
788
+ }
704
789
  }
705
790
  return
706
791
  }
@@ -892,22 +977,44 @@ function connect() {
892
977
  const ws = new WebSocket(managerUrl.href)
893
978
  wsRef = ws
894
979
 
895
- const pingMs = 10_000
980
+ const PING_INTERVAL_MS = 10_000
981
+ const PONG_TIMEOUT_MS = 8_000 // must be < PING_INTERVAL_MS
896
982
  let pingTimer = null
983
+ let pongTimer = null
984
+ let isAlive = false
985
+
986
+ const stopHeartbeat = () => {
987
+ if (pingTimer) { clearInterval(pingTimer); pingTimer = null }
988
+ if (pongTimer) { clearTimeout(pongTimer); pongTimer = null }
989
+ }
897
990
 
898
991
  ws.on('open', async () => {
899
992
  log(chalk.green('Connected to Virtual Office'))
900
993
  reconnectAttempts = 0
994
+ isAlive = true
901
995
 
902
- // Keepalive pings
996
+ // Heartbeat: ping + pong timeout detection (matches cloud managerHostProxy pattern)
903
997
  pingTimer = setInterval(() => {
904
- if (ws.readyState === WebSocket.OPEN) {
905
- try { ws.ping() } catch { /* ignore */ }
998
+ if (ws.readyState !== WebSocket.OPEN) { stopHeartbeat(); return }
999
+ if (!isAlive) {
1000
+ log(chalk.red('Heartbeat timeout — no pong received, forcing reconnect'))
1001
+ stopHeartbeat()
1002
+ try { ws.terminate() } catch { /* ignore */ }
1003
+ return
906
1004
  }
907
- }, pingMs)
1005
+ isAlive = false
1006
+ try { ws.ping() } catch { /* ignore */ }
1007
+ }, PING_INTERVAL_MS)
1008
+
1009
+ ws.on('pong', () => { isAlive = true })
908
1010
 
909
1011
  sendHostMeta(ws)
910
1012
 
1013
+ // Registry heartbeat: report liveness independently of WS ping/pong.
1014
+ // This lets Chat Bridge know the agent process is alive even during
1015
+ // brief WS reconnection windows.
1016
+ startRegistryHeartbeat()
1017
+
911
1018
  // Build system prompt and register MCP server (first-class citizen setup)
912
1019
  log(chalk.blue('Setting up agent identity and tools...'))
913
1020
  if (!cachedSystemPrompt) {
@@ -950,7 +1057,9 @@ function connect() {
950
1057
  const reasonStr = reason?.toString() || ''
951
1058
  log(chalk.red(`Disconnected (${code} ${reasonStr})`))
952
1059
  wsRef = null
953
- if (pingTimer) clearInterval(pingTimer)
1060
+ stopHeartbeat()
1061
+ // Keep registry heartbeat running during reconnection — it's independent
1062
+ // of the WS connection and tells Chat Bridge the process is still alive.
954
1063
 
955
1064
  // Kill all active CLI processes on disconnect
956
1065
  for (const [, entry] of activeChildren) {
@@ -983,6 +1092,53 @@ function scheduleReconnect() {
983
1092
  setTimeout(connect, delay)
984
1093
  }
985
1094
 
1095
+ // ── Registry Heartbeat ────────────────────────────────────────────────────
1096
+ // Reports agent liveness to Registry via Chat Bridge, independent of WS state.
1097
+ // This ensures Chat Bridge knows the agent process is alive even during
1098
+ // WS reconnection (up to 30s exponential backoff window).
1099
+
1100
+ function startRegistryHeartbeat() {
1101
+ if (registryHeartbeatTimer) return // already running
1102
+ const agentHandle = argv.agent
1103
+ if (!agentHandle) return
1104
+
1105
+ const chatBridgeHttpUrl =
1106
+ process.env.CHAT_BRIDGE_HTTP_URL ||
1107
+ process.env.CHAT_BRIDGE_URL ||
1108
+ process.env.CHAT_BRIDGE_BASE_URL ||
1109
+ 'https://chatbridge.aladdinagi.xyz'
1110
+
1111
+ const sendHeartbeat = async () => {
1112
+ try {
1113
+ const url = `${chatBridgeHttpUrl}/api/${encodeURIComponent(agentHandle)}/heartbeat`
1114
+ const resp = await fetch(url, {
1115
+ method: 'POST',
1116
+ headers: { 'Content-Type': 'application/json' },
1117
+ body: JSON.stringify({ timestamp: new Date().toISOString() }),
1118
+ signal: AbortSignal.timeout(10000),
1119
+ })
1120
+ if (!resp.ok) {
1121
+ const text = await resp.text().catch(() => '')
1122
+ log(chalk.dim(`[heartbeat] Registry heartbeat failed: ${resp.status} ${text}`))
1123
+ }
1124
+ } catch (err) {
1125
+ // Silent — heartbeat failures are non-critical
1126
+ log(chalk.dim(`[heartbeat] ${err.message}`))
1127
+ }
1128
+ }
1129
+
1130
+ // Send immediately, then every 30s
1131
+ sendHeartbeat()
1132
+ registryHeartbeatTimer = setInterval(sendHeartbeat, REGISTRY_HEARTBEAT_INTERVAL_MS)
1133
+ }
1134
+
1135
+ function stopRegistryHeartbeat() {
1136
+ if (registryHeartbeatTimer) {
1137
+ clearInterval(registryHeartbeatTimer)
1138
+ registryHeartbeatTimer = null
1139
+ }
1140
+ }
1141
+
986
1142
  // ── Local Device Connection (file system access for Workspace Panel) ───────
987
1143
  // Second WebSocket to Chat Bridge /local-agent — registers as a local device
988
1144
  // so the web UI can browse local files via Workspace Panel.
@@ -1006,8 +1162,17 @@ function connectLocalDevice() {
1006
1162
  const dws = new WebSocket(deviceUrl)
1007
1163
  deviceWsRef = dws
1008
1164
 
1165
+ const DEVICE_PING_INTERVAL_MS = 25_000
1166
+ const DEVICE_PONG_TIMEOUT_MS = 10_000
1167
+ let deviceIsAlive = false
1168
+
1169
+ const stopDeviceHeartbeat = () => {
1170
+ if (devicePingTimer) { clearInterval(devicePingTimer); devicePingTimer = null }
1171
+ }
1172
+
1009
1173
  dws.on('open', () => {
1010
1174
  log(chalk.green('Local device connected'))
1175
+ deviceIsAlive = true
1011
1176
  const agentHandle = argv.agent
1012
1177
  const officeId = agentHandle.split('.').slice(1).join('.')
1013
1178
  // Register as agent-bound device (NOT office-wide).
@@ -1029,13 +1194,21 @@ function connectLocalDevice() {
1029
1194
  },
1030
1195
  }))
1031
1196
 
1032
- // Keepalive ping every 30s to prevent Cloudflare/ALB idle timeout
1033
- if (devicePingTimer) clearInterval(devicePingTimer)
1197
+ // Heartbeat: ping + pong timeout detection
1198
+ stopDeviceHeartbeat()
1034
1199
  devicePingTimer = setInterval(() => {
1035
- if (dws.readyState === WebSocket.OPEN) {
1036
- try { dws.ping() } catch { /* ignore */ }
1200
+ if (dws.readyState !== WebSocket.OPEN) { stopDeviceHeartbeat(); return }
1201
+ if (!deviceIsAlive) {
1202
+ log(chalk.red('[device] Heartbeat timeout — no pong received, forcing reconnect'))
1203
+ stopDeviceHeartbeat()
1204
+ try { dws.terminate() } catch { /* ignore */ }
1205
+ return
1037
1206
  }
1038
- }, 30_000)
1207
+ deviceIsAlive = false
1208
+ try { dws.ping() } catch { /* ignore */ }
1209
+ }, DEVICE_PING_INTERVAL_MS)
1210
+
1211
+ dws.on('pong', () => { deviceIsAlive = true })
1039
1212
  })
1040
1213
 
1041
1214
  dws.on('message', async (data) => {
@@ -1058,7 +1231,7 @@ function connectLocalDevice() {
1058
1231
  dws.on('close', (code) => {
1059
1232
  log(chalk.yellow(`Local device disconnected (${code})`))
1060
1233
  deviceWsRef = null
1061
- if (devicePingTimer) { clearInterval(devicePingTimer); devicePingTimer = null }
1234
+ stopDeviceHeartbeat()
1062
1235
  // Reconnect after delay (only if not shutting down)
1063
1236
  if (code !== 1000) {
1064
1237
  deviceReconnectTimer = setTimeout(connectLocalDevice, 5000)
@@ -1139,7 +1312,8 @@ async function executeLocalTool(toolName, params) {
1139
1312
  function shutdown() {
1140
1313
  console.log('')
1141
1314
  log(chalk.yellow('Clocking out...'))
1142
- // Unregister MCP server so it doesn't linger in ~/.claude.json
1315
+ // Stop registry heartbeat and unregister MCP server
1316
+ stopRegistryHeartbeat()
1143
1317
  unregisterMcpServer()
1144
1318
  for (const [, entry] of activeChildren) {
1145
1319
  try { entry?.child?.kill('SIGTERM') } catch { /* ignore */ }
@@ -1186,6 +1360,7 @@ async function startup() {
1186
1360
  argv.agent = result.agent
1187
1361
  argv.token = result.token
1188
1362
  hostId = result.agent
1363
+ label = result.agent.split('.')[0] || result.agent
1189
1364
 
1190
1365
  // Rebuild manager URL with new agent/token
1191
1366
  const newManagerUrl = new URL(argv.manager)
package/onboarding.js CHANGED
@@ -30,6 +30,55 @@ const CHAT_BRIDGE_URL =
30
30
 
31
31
  const PKG_NAME = '@office-xyz/claude-code'
32
32
 
33
+ // ── Pre-flight Check ──────────────────────────────────────────────────────
34
+
35
+ async function checkClaudeCodeCli() {
36
+ const { execSync } = await import('child_process')
37
+
38
+ // Check if claude is installed
39
+ let version = null
40
+ try {
41
+ version = execSync('claude --version', { encoding: 'utf-8', timeout: 5000 }).trim()
42
+ } catch {
43
+ console.log(chalk.red(' Claude Code CLI is not installed.'))
44
+ console.log('')
45
+ console.log(chalk.white(' Install it:'))
46
+ console.log(chalk.cyan(' curl -fsSL https://claude.ai/install.sh | bash'))
47
+ console.log('')
48
+ console.log(chalk.dim(' Or via npm: npm install -g @anthropic-ai/claude-code'))
49
+ console.log('')
50
+ process.exit(1)
51
+ }
52
+
53
+ console.log(chalk.dim(` Detected: Claude Code ${version}`))
54
+
55
+ // Check if logged in by running a quick command
56
+ try {
57
+ const result = execSync('claude -p "ping" --output-format text --max-turns 1', {
58
+ encoding: 'utf-8',
59
+ timeout: 15000,
60
+ env: { ...process.env, ANTHROPIC_API_KEY: undefined },
61
+ })
62
+ // If we get any response, auth works
63
+ console.log(chalk.dim(' Auth: Claude login session ✓'))
64
+ } catch (err) {
65
+ const msg = (err.stderr || err.stdout || err.message || '').toLowerCase()
66
+ if (msg.includes('credit') || msg.includes('balance') || msg.includes('unauthorized') || msg.includes('auth')) {
67
+ console.log(chalk.red(' Claude Code is not logged in.'))
68
+ console.log('')
69
+ console.log(chalk.white(' Run this first:'))
70
+ console.log(chalk.cyan(' claude login'))
71
+ console.log('')
72
+ console.log(chalk.dim(' You need a Claude Pro, Max, or Team subscription.'))
73
+ console.log('')
74
+ process.exit(1)
75
+ }
76
+ // Other errors (timeout, etc.) — proceed anyway, might work
77
+ console.log(chalk.dim(' Auth: could not verify (will try anyway)'))
78
+ }
79
+ console.log('')
80
+ }
81
+
33
82
  // ── Update Check ──────────────────────────────────────────────────────────
34
83
 
35
84
  /**
@@ -104,6 +153,8 @@ export function printClockInBanner({ agentHandle, model, seat, workspace }) {
104
153
  }
105
154
  console.log(chalk.cyan(' │') + chalk.dim(' Workspace: ') + chalk.white(workspace || process.cwd()))
106
155
  console.log(chalk.cyan(' │') + ' ' + chalk.cyan('│'))
156
+ console.log(chalk.cyan(' │') + chalk.dim(' Web UI: ') + chalk.underline.cyan('https://beta.office.xyz'))
157
+ console.log(chalk.cyan(' │') + ' ' + chalk.cyan('│'))
107
158
  console.log(chalk.cyan(' │') + chalk.yellow(' Press Ctrl+C to clock out') + ' ' + chalk.cyan('│'))
108
159
  console.log(chalk.cyan(' └─────────────────────────────────────────┘'))
109
160
  console.log('')
@@ -280,9 +331,135 @@ async function createOffice(sessionToken) {
280
331
  }
281
332
  }
282
333
 
283
- // ── Agent Hire ────────────────────────────────────────────────────────────
334
+ // ── Role Selection ────────────────────────────────────────────────────────
335
+
336
+ const ROLE_CATEGORIES = [
337
+ { id: 'business', icon: '💼', label: 'Business', description: 'Operations, Marketing, Sales, Support, Executive, HR' },
338
+ { id: 'science', icon: '🔬', label: 'Science', description: 'Research, Data Science, Bioinformatics, Lab, Clinical' },
339
+ { id: 'developer', icon: '💻', label: 'Developer', description: 'Full-Stack, Frontend, Backend, DevOps, AI Engineering' },
340
+ { id: 'education', icon: '📖', label: 'Education', description: 'Learning, Tutoring, Knowledge Exploration' },
341
+ ]
342
+
343
+ const ROLES = [
344
+ // Business
345
+ { id: 'operations', category: 'business', icon: '📈', label: 'Operations' },
346
+ { id: 'marketing', category: 'business', icon: '📣', label: 'Marketing' },
347
+ { id: 'sales', category: 'business', icon: '🤝', label: 'Sales' },
348
+ { id: 'support', category: 'business', icon: '💬', label: 'Support' },
349
+ { id: 'executive', category: 'business', icon: '👔', label: 'Executive' },
350
+ { id: 'hr', category: 'business', icon: '👥', label: 'HR' },
351
+ // Science
352
+ { id: 'researcher', category: 'science', icon: '🔬', label: 'Researcher' },
353
+ { id: 'data-scientist', category: 'science', icon: '📊', label: 'Data Scientist' },
354
+ { id: 'bioinformatics', category: 'science', icon: '🧬', label: 'Bioinformatics' },
355
+ { id: 'lab-manager', category: 'science', icon: '🧪', label: 'Lab Manager' },
356
+ { id: 'clinical', category: 'science', icon: '🏥', label: 'Clinical' },
357
+ // Developer
358
+ { id: 'fullstack', category: 'developer', icon: '🖥️', label: 'Full-Stack' },
359
+ { id: 'frontend', category: 'developer', icon: '🎨', label: 'Frontend' },
360
+ { id: 'backend', category: 'developer', icon: '⚙️', label: 'Backend' },
361
+ { id: 'devops', category: 'developer', icon: '🔧', label: 'DevOps' },
362
+ { id: 'ai-engineer', category: 'developer', icon: '🤖', label: 'AI Engineer' },
363
+ // Education
364
+ { id: 'learner', category: 'education', icon: '📖', label: 'Learner' },
365
+ ]
366
+
367
+ async function selectRole() {
368
+ const category = await select({
369
+ message: 'What does your agent do?',
370
+ choices: ROLE_CATEGORIES.map(c => ({
371
+ name: `${c.icon} ${c.label} ${chalk.dim(c.description)}`,
372
+ value: c.id,
373
+ })),
374
+ })
375
+
376
+ const rolesInCategory = ROLES.filter(r => r.category === category)
377
+
378
+ const roleId = await select({
379
+ message: 'Select a role:',
380
+ choices: rolesInCategory.map(r => ({
381
+ name: `${r.icon} ${r.label}`,
382
+ value: r.id,
383
+ })),
384
+ })
284
385
 
285
- async function hireAgent(officeId, sessionToken) {
386
+ const role = ROLES.find(r => r.id === roleId)
387
+ return { roleId, roleCategory: category, roleLabel: role?.label || roleId }
388
+ }
389
+
390
+ // ── Agent Selection / Hire ─────────────────────────────────────────────────
391
+
392
+ const PROVIDER_LABELS = {
393
+ claude: 'Claude Code',
394
+ anthropic: 'Claude',
395
+ openai: 'Codex',
396
+ gemini: 'Gemini',
397
+ deepseek: 'DeepSeek',
398
+ qwen: 'Qwen',
399
+ kimi: 'Kimi',
400
+ ollama: 'Ollama',
401
+ }
402
+
403
+ async function selectOrHireAgent(officeId, sessionToken) {
404
+ // 1. Check for existing agents in this office
405
+ let existingAgents = []
406
+ try {
407
+ const data = await api('GET', `/api/cli/office/${encodeURIComponent(officeId)}/agents`, null, sessionToken)
408
+ existingAgents = data.agents || []
409
+ } catch {
410
+ // Continue — will go straight to hire
411
+ }
412
+
413
+ // Only show local Claude agents — cloud agents and non-Claude agents can't be clocked in from this CLI
414
+ const localAgents = existingAgents.filter(a =>
415
+ a.deploymentMode === 'local' &&
416
+ (a.provider === 'claude' || a.provider === 'claude-code' || a.provider === 'anthropic')
417
+ )
418
+
419
+ if (localAgents.length > 0) {
420
+ // Show local agents + option to hire new
421
+ const providerLabel = (p) => PROVIDER_LABELS[p] || p || 'unknown'
422
+
423
+ const choices = [
424
+ ...localAgents.map(a => ({
425
+ name: `${a.name} ${chalk.dim(`${a.role} · ${providerLabel(a.provider)} · ${a.seat || 'no seat'}`)}`,
426
+ value: a.id,
427
+ })),
428
+ {
429
+ name: chalk.cyan('+ Hire a new agent'),
430
+ value: '__hire__',
431
+ },
432
+ ]
433
+
434
+ const choice = await select({
435
+ message: 'Select an agent to clock in:',
436
+ choices,
437
+ })
438
+
439
+ if (choice !== '__hire__') {
440
+ // Reconnect existing agent
441
+ const agent = localAgents.find(a => a.id === choice)
442
+ const spinner = ora('Reconnecting...').start()
443
+ try {
444
+ const result = await api('POST', '/api/cli/office/hire', {
445
+ officeId,
446
+ agentName: agent.name,
447
+ provider: 'claude-code',
448
+ }, sessionToken)
449
+
450
+ spinner.succeed(`Reconnected: ${chalk.bold(result.agentHandle)} ${chalk.dim(`(${agent.role})`)}${result.seat ? chalk.dim(` · seat: ${result.seat}`) : ''}`)
451
+ return result
452
+ } catch (err) {
453
+ spinner.fail(`Failed to reconnect: ${err.message}`)
454
+ process.exit(1)
455
+ }
456
+ }
457
+ }
458
+
459
+ // 2. Hire new agent: select role
460
+ const { roleId, roleCategory, roleLabel } = await selectRole()
461
+
462
+ // 3. Name agent
286
463
  const agentName = await input({
287
464
  message: 'Name your Claude Code agent:',
288
465
  validate: (v) => {
@@ -294,15 +471,18 @@ async function hireAgent(officeId, sessionToken) {
294
471
  transformer: (v) => v.toLowerCase(),
295
472
  })
296
473
 
474
+ // 4. Hire
297
475
  const spinner = ora('Setting up agent...').start()
298
476
  try {
299
477
  const result = await api('POST', '/api/cli/office/hire', {
300
478
  officeId,
301
479
  agentName: agentName.trim().toLowerCase(),
302
480
  provider: 'claude-code',
481
+ roleId,
482
+ roleCategory,
303
483
  }, sessionToken)
304
484
 
305
- spinner.succeed(`Agent ready: ${chalk.bold(result.agentHandle)}${result.seat ? chalk.dim(` (seat: ${result.seat})`) : ''}`)
485
+ spinner.succeed(`Agent ready: ${chalk.bold(result.agentHandle)} ${chalk.dim(`(${roleLabel})`)}${result.seat ? chalk.dim(` · seat: ${result.seat}`) : ''}`)
306
486
  return result
307
487
  } catch (err) {
308
488
  spinner.fail(`Failed to create agent: ${err.message}`)
@@ -324,6 +504,9 @@ export async function runOnboarding() {
324
504
  // Check for updates (non-blocking, runs in background)
325
505
  checkForUpdate()
326
506
 
507
+ // 0. Pre-flight: check Claude Code CLI is installed and logged in
508
+ await checkClaudeCodeCli()
509
+
327
510
  // 1. Check cached session
328
511
  const cached = loadSession()
329
512
 
@@ -335,6 +518,7 @@ export async function runOnboarding() {
335
518
  if (validation.success) {
336
519
  spinner.succeed(`Welcome back, ${chalk.bold(cached.email || cached.displayName || 'user')}!`)
337
520
  console.log(chalk.dim(` Reconnecting as ${cached.lastAgent.handle}...`))
521
+ console.log(chalk.dim(` Web interface: ${chalk.underline.cyan('https://beta.office.xyz')}`))
338
522
  return {
339
523
  agent: cached.lastAgent.handle,
340
524
  token: cached.lastAgent.connectionToken,
@@ -353,8 +537,8 @@ export async function runOnboarding() {
353
537
  // 3. Select or create office
354
538
  const { officeId, domain } = await selectOrCreateOffice(offices, session.sessionToken)
355
539
 
356
- // 4. Name and hire agent
357
- const hired = await hireAgent(officeId, session.sessionToken)
540
+ // 4. Select existing agent or hire new
541
+ const hired = await selectOrHireAgent(officeId, session.sessionToken)
358
542
 
359
543
  // 5. Save session
360
544
  saveSession({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@office-xyz/claude-code",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Connect Claude Code to your Virtual Office — manage your AI agents from the terminal",
5
5
  "type": "module",
6
6
  "bin": {