@office-xyz/claude-code 0.1.7 → 0.1.9

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 CHANGED
@@ -37,6 +37,13 @@ const TOOL_NAME_MAP = {
37
37
  execute_command: 'Bash', read_file: 'Read', write_file: 'Write', edit_file: 'Edit',
38
38
  search: 'Grep', run_command: 'Bash', code_edit: 'FileEdit', code_search: 'Grep',
39
39
  terminal: 'Bash', file_operation: 'FileEdit',
40
+ // Claude server-side tools (content_block type → display name)
41
+ server_tool_use: 'ServerTool', web_search_tool_result: 'WebSearch',
42
+ web_fetch_tool_result: 'WebFetch', code_execution_tool_result: 'CodeExecution',
43
+ mcp_tool_use: 'Mcp', mcp_tool_result: 'Mcp',
44
+ bash_code_execution_tool_result: 'Bash',
45
+ text_editor_code_execution_tool_result: 'Edit',
46
+ tool_search_tool_result: 'ToolSearch', container_upload: 'Upload',
40
47
  // General aliases
41
48
  bash: 'Bash', grep: 'Grep', glob: 'Glob', ls: 'LS',
42
49
  }
@@ -98,6 +105,10 @@ const argv = yargs(hideBin(process.argv))
98
105
  type: 'string',
99
106
  describe: 'Override the CLI binary (e.g. /usr/local/bin/claude)',
100
107
  })
108
+ .option('channel', {
109
+ type: 'string',
110
+ describe: 'Only show messages from this channel (web, telegram, slack, discord, office, feishu, wechat)',
111
+ })
101
112
  .example('$0', 'Interactive setup (login, create office, name agent)')
102
113
  .example('$0 --agent claude.my.office.xyz --token xxx', 'Direct connect (skip login)')
103
114
  .help()
@@ -253,16 +264,28 @@ const model = argv.model || providerConfig.defaultModel
253
264
 
254
265
  // ── Session tracking ───────────────────────────────────────────────────────
255
266
  // Map VO sessionId → Claude session_id for conversation continuity.
256
- // Cleared on clock-in to avoid stale sessions that ignore --append-system-prompt.
267
+ // Persisted to disk so sessions survive clock-out / clock-in cycles.
268
+ // --resume and --append-system-prompt coexist fine, so resumed sessions
269
+ // still pick up fresh system prompts and MCP tools (registered globally).
257
270
  const SESSION_MAP_FILE = path.join(os.tmpdir(), `vo-sessions-${(argv.agent || 'pending').replace(/\./g, '-')}.json`)
258
271
  const sessionMap = new Map()
259
272
 
260
- // Clear stale sessions on startup — stale sessions from previous clock-ins
261
- // ignore new --append-system-prompt and MCP tools.
273
+ // Load persisted sessions from previous clock-in (if any)
262
274
  try {
263
- require('fs').unlinkSync(SESSION_MAP_FILE)
264
- // Will be re-created when first session is mapped
265
- } catch { /* no file to delete */ }
275
+ const raw = require('fs').readFileSync(SESSION_MAP_FILE, 'utf-8')
276
+ const parsed = JSON.parse(raw)
277
+ // Support both formats: Array of entries [[k,v], ...] and Object {k: v}
278
+ const entries = Array.isArray(parsed) ? parsed : Object.entries(parsed)
279
+ for (const [k, v] of entries) sessionMap.set(k, v)
280
+ log(chalk.dim(`Restored ${sessionMap.size} session mapping(s) from previous clock-in`))
281
+ } catch { /* no file or invalid — start fresh */ }
282
+
283
+ /** Persist session map to disk (fire-and-forget) */
284
+ function persistSessionMap() {
285
+ try {
286
+ require('fs').writeFileSync(SESSION_MAP_FILE, JSON.stringify([...sessionMap]), 'utf-8')
287
+ } catch { /* best-effort */ }
288
+ }
266
289
 
267
290
  // Track active command processes PER SESSION for concurrent conversation support.
268
291
  // Key: sessionId, Value: { child, commandId }. Different clients (web, Telegram)
@@ -346,7 +369,10 @@ async function registerMcpServer() {
346
369
  try {
347
370
  const agentHandle = argv.agent
348
371
  const officeId = agentHandle.split('.').slice(1).join('.')
349
- const mcpServerPath = path.resolve(__dirname, '../mcp-server/skyoffice-mcp-server.js')
372
+ // Use the lightweight MCP server bundled with the npm package.
373
+ // It fetches tool schemas from Chat Bridge and proxies all calls via HTTP,
374
+ // so it doesn't need the 150+ monorepo files that the full MCP server requires.
375
+ const mcpServerPath = path.resolve(__dirname, 'mcp-server-lite.cjs')
350
376
 
351
377
  const chatBridgeUrl = process.env.CHAT_BRIDGE_URL ||
352
378
  process.env.CHAT_BRIDGE_BASE_URL ||
@@ -397,6 +423,47 @@ function unregisterMcpServer() {
397
423
  } catch { /* ignore */ }
398
424
  }
399
425
 
426
+ // ── Channel resolution ─────────────────────────────────────────────────────
427
+
428
+ /**
429
+ * Resolve message channel from platformInfo / metadata / sessionId.
430
+ * Returns { type, color, sender, chatId } for display formatting.
431
+ */
432
+ function resolveChannel(message) {
433
+ const platformInfo = message.platformInfo || {}
434
+ const meta = message.metadata || {}
435
+ const source = meta.source || platformInfo.clientType || ''
436
+ const sessionId = message.sessionId || ''
437
+
438
+ if (source.includes('telegram')) {
439
+ const from = meta.telegram?.from
440
+ const name = from?.username ? `@${from.username}` : from?.firstName || 'user'
441
+ return { type: 'Telegram', color: 'blue', sender: name, chatId: platformInfo.chatId }
442
+ }
443
+ if (source.includes('slack')) {
444
+ const channel = meta.slack?.channelId || platformInfo.channelId || ''
445
+ const user = meta.slack?.username || 'user'
446
+ return { type: 'Slack', color: 'magenta', sender: channel ? `#${channel} — ${user}` : user, chatId: null }
447
+ }
448
+ if (source.includes('discord')) {
449
+ return { type: 'Discord', color: 'blueBright', sender: meta.discord?.username || 'user', chatId: null }
450
+ }
451
+ if (source.includes('feishu') || source.includes('lark')) {
452
+ return { type: 'Feishu', color: 'cyan', sender: meta.feishu?.username || 'user', chatId: null }
453
+ }
454
+ if (source.includes('wecom') || source.includes('wechat') || source.includes('whatsapp')) {
455
+ const label = source.includes('whatsapp') ? 'WhatsApp' : 'WeChat'
456
+ return { type: label, color: 'green', sender: 'user', chatId: null }
457
+ }
458
+ if (sessionId.includes('office-wide')) {
459
+ const senderParts = sessionId.split('--')
460
+ return { type: 'Office Chat', color: 'greenBright', sender: senderParts[0] || 'colleague', chatId: null }
461
+ }
462
+ // Default: Web dialog
463
+ const userId = sessionId.split('--')[1] || 'user'
464
+ return { type: 'Web', color: 'cyanBright', sender: userId.slice(0, 20), chatId: null }
465
+ }
466
+
400
467
  // ── Message handling ───────────────────────────────────────────────────────
401
468
 
402
469
  function sendJSON(payload) {
@@ -454,13 +521,21 @@ async function handleMessage(message) {
454
521
  const sessionId = message.sessionId || null
455
522
  const commandId = message.commandId || message.messageId || `cmd-${Date.now()}`
456
523
 
457
- const sessionLabel = sessionId ? sessionId.split('--')[1]?.slice(0, 15) || sessionId.slice(0, 20) : 'default'
458
- log(chalk.cyan(`→ [${sessionLabel}] ${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`))
524
+ const channel = resolveChannel(message)
525
+
526
+ // --channel filter: skip messages not matching the requested channel
527
+ if (argv.channel && !channel.type.toLowerCase().includes(argv.channel.toLowerCase())) return
528
+
529
+ const badge = chalk[channel.color](`[${channel.type}]`)
530
+ const time = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
531
+ const sender = chalk.dim(channel.sender)
532
+ log(`${badge} ${chalk.dim(time)} ${sender}`)
533
+ log(` ${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`)
459
534
 
460
535
  // Kill previous command for THIS SESSION only. Other sessions continue in parallel.
461
536
  const prev = sessionId ? activeChildren.get(sessionId) : null
462
537
  if (prev?.child) {
463
- log(chalk.dim(`[${sessionLabel}] Killing previous command for same session`))
538
+ log(chalk.dim(`[${sessionId}] Killing previous command for same session`))
464
539
  sendJSON({
465
540
  type: 'streaming.aborted',
466
541
  sessionId,
@@ -482,6 +557,9 @@ async function handleMessage(message) {
482
557
  const claudeSessionId = sessionId ? sessionMap.get(sessionId) : null
483
558
  if (claudeSessionId && providerConfig.resumeFlag) {
484
559
  args.push(providerConfig.resumeFlag, claudeSessionId)
560
+ log(chalk.green(`[session] Resuming: ${sessionId} → ${claudeSessionId}`))
561
+ } else if (sessionId) {
562
+ log(chalk.yellow(`[session] No resume binding for ${sessionId} (map size: ${sessionMap.size})`))
485
563
  }
486
564
  // System prompt + platform context injection.
487
565
  // shell: false — no escaping needed, args passed directly.
@@ -523,6 +601,19 @@ async function handleMessage(message) {
523
601
  startedAt: new Date().toISOString(),
524
602
  })
525
603
 
604
+ // Heartbeat: Send periodic streaming.heartbeat during long-running claude -p execution.
605
+ // This prevents WS proxies (Cloudflare 100s, ALB 60s) from killing idle connections
606
+ // when Claude Code is thinking but not emitting any streaming tokens.
607
+ const HEARTBEAT_INTERVAL_MS = 25_000 // 25s — below Cloudflare/ALB idle timeouts
608
+ const heartbeatTimer = setInterval(() => {
609
+ sendJSON({
610
+ type: 'streaming.heartbeat',
611
+ sessionId,
612
+ commandId,
613
+ timestamp: Date.now(),
614
+ })
615
+ }, HEARTBEAT_INTERVAL_MS)
616
+
526
617
  // 2. Spawn the CLI process
527
618
  // shell: false — args are passed directly to the process as an array,
528
619
  // avoiding ALL shell interpretation issues. The command is resolved via
@@ -665,6 +756,11 @@ async function handleMessage(message) {
665
756
  return
666
757
  }
667
758
 
759
+ // Citations delta — ignore silently (citations are embedded in final text)
760
+ if (streamEvent?.type === 'content_block_delta' && streamEvent?.delta?.type === 'citations_delta') {
761
+ return
762
+ }
763
+
668
764
  // Thinking deltas
669
765
  if (streamEvent?.type === 'content_block_delta' && streamEvent?.delta?.type === 'thinking_delta') {
670
766
  const deltaText = streamEvent.delta.thinking || streamEvent.delta.text || ''
@@ -719,19 +815,42 @@ async function handleMessage(message) {
719
815
  return
720
816
  }
721
817
 
722
- if (block?.type === 'tool_use') {
723
- const toolUseId = block.id || `tool-${now}-${Math.random().toString(36).slice(2, 8)}`
818
+ // All tool-like block types: tool_use (client), server_tool_use (Anthropic server),
819
+ // mcp_tool_use, and result blocks that appear as content_block_start in stream-json.
820
+ // The original Claude CLI sets "tool-input" spinner state for all of these.
821
+ const TOOL_BLOCK_TYPES = new Set([
822
+ 'tool_use', 'server_tool_use', 'mcp_tool_use',
823
+ 'web_search_tool_result', 'web_fetch_tool_result',
824
+ 'code_execution_tool_result', 'bash_code_execution_tool_result',
825
+ 'text_editor_code_execution_tool_result', 'tool_search_tool_result',
826
+ 'mcp_tool_result', 'container_upload',
827
+ ])
828
+ if (TOOL_BLOCK_TYPES.has(block?.type)) {
829
+ // For server_tool_use, the tool name is in block.name (e.g. "web_search")
830
+ // For result blocks, derive name from block type itself
831
+ const rawName = block.name || block.type
832
+ const toolUseId = block.id || block.tool_use_id || `tool-${now}-${Math.random().toString(36).slice(2, 8)}`
724
833
  if (blockIndex !== null) {
725
834
  activeToolsByIndex.set(blockIndex, {
726
835
  toolUseId,
727
- toolName: block.name || 'tool',
728
- input: block.input,
836
+ toolName: rawName,
837
+ input: block.input || {},
729
838
  })
730
839
  }
731
840
  emitToolStart({
732
841
  toolUseId,
733
- toolName: block.name || 'tool',
734
- input: block.input,
842
+ toolName: rawName,
843
+ input: block.input || {},
844
+ timestamp: now,
845
+ })
846
+ }
847
+
848
+ // Compaction events — notify frontend that context is being compressed
849
+ if (block?.type === 'compaction') {
850
+ emitToolStart({
851
+ toolUseId: block.id || `compaction-${now}`,
852
+ toolName: 'Compaction',
853
+ input: {},
735
854
  timestamp: now,
736
855
  })
737
856
  }
@@ -796,10 +915,11 @@ async function handleMessage(message) {
796
915
  if (block.type === 'text' && block.text) {
797
916
  if (!fullText) fullText = block.text
798
917
  }
799
- // Extract complete tool_use input — the assistant message contains the
918
+ // Extract complete tool input — the assistant message contains the
800
919
  // fully accumulated input (unlike content_block_start which has {}).
801
- // This allows us to retroactively update tool events with real input data.
802
- if (block.type === 'tool_use' && block.id && block.input) {
920
+ // This applies to tool_use, server_tool_use, and mcp_tool_use blocks.
921
+ const isToolBlock = block.type === 'tool_use' || block.type === 'server_tool_use' || block.type === 'mcp_tool_use'
922
+ if (isToolBlock && block.id && block.input) {
803
923
  const existing = activeToolsById.get(block.id)
804
924
  if (existing && (!existing.input || Object.keys(existing.input).length === 0)) {
805
925
  existing.input = block.input
@@ -852,6 +972,39 @@ async function handleMessage(message) {
852
972
  }
853
973
  return
854
974
  }
975
+
976
+ // System events — forward plan mode, hooks, and other system subtypes
977
+ // These are top-level events (not wrapped in stream_event) that the CLI
978
+ // emits in --verbose stream-json mode for UI state changes.
979
+ if (event.type === 'system') {
980
+ const subtype = event.subtype
981
+ // Plan mode transitions — let frontend know agent entered/exited planning
982
+ if (subtype === 'plan_mode' || subtype === 'plan_mode_exit' || subtype === 'plan_mode_reentry') {
983
+ sendJSON({ type: 'system_event', sessionId, commandId, event: { subtype, timestamp: now } })
984
+ return
985
+ }
986
+ // Hook lifecycle — show user that hooks are running
987
+ if (subtype === 'hook_started' || subtype === 'hook_progress' || subtype === 'hook_response') {
988
+ sendJSON({ type: 'system_event', sessionId, commandId, event: { subtype, hookName: event.hook_name, timestamp: now } })
989
+ return
990
+ }
991
+ // Task/agent notifications (subagent spawned, progress, etc.)
992
+ if (subtype === 'task_notification' || subtype === 'task_progress') {
993
+ sendJSON({ type: 'system_event', sessionId, commandId, event: { subtype, message: event.message, timestamp: now } })
994
+ return
995
+ }
996
+ // MCP progress
997
+ if (subtype === 'mcp_message' || subtype === 'mcp_progress') {
998
+ sendJSON({ type: 'system_event', sessionId, commandId, event: { subtype, message: event.message, timestamp: now } })
999
+ return
1000
+ }
1001
+ // Context compaction boundaries
1002
+ if (subtype === 'compact_boundary' || subtype === 'microcompact_boundary') {
1003
+ sendJSON({ type: 'system_event', sessionId, commandId, event: { subtype, timestamp: now } })
1004
+ return
1005
+ }
1006
+ return
1007
+ }
855
1008
  } catch {
856
1009
  // Not JSON or unrecognized format — ignore
857
1010
  }
@@ -867,6 +1020,7 @@ async function handleMessage(message) {
867
1020
 
868
1021
  // 4. On process exit, send completion events
869
1022
  child.on('close', (code) => {
1023
+ clearInterval(heartbeatTimer) // Stop heartbeat — task is done
870
1024
  if (sessionId && activeChildren.get(sessionId)?.child === child) activeChildren.delete(sessionId)
871
1025
 
872
1026
  // Clean up system prompt temp file
@@ -877,12 +1031,13 @@ async function handleMessage(message) {
877
1031
  // Store Claude session_id for conversation continuity
878
1032
  if (resultSessionId && sessionId) {
879
1033
  sessionMap.set(sessionId, resultSessionId)
1034
+ persistSessionMap()
880
1035
  log(chalk.dim(`Session mapped: ${sessionId} → ${resultSessionId}`))
881
1036
  }
882
1037
 
883
1038
  if (fullText) {
884
1039
  process.stdout.write('\n')
885
- log(chalk.green(`← ${fullText.slice(0, 80)}${fullText.length > 80 ? '...' : ''}`))
1040
+ log(chalk[channel.color](`[${channel.type}] ←`) + chalk.dim(` ${fullText.slice(0, 120)}${fullText.length > 120 ? '...' : ''}`))
886
1041
  }
887
1042
 
888
1043
  // Send streaming.completed
@@ -964,6 +1119,7 @@ async function handleMessage(message) {
964
1119
  })
965
1120
 
966
1121
  child.on('error', (err) => {
1122
+ clearInterval(heartbeatTimer) // Stop heartbeat on error
967
1123
  log(chalk.red(`CLI process error: ${err.message}`))
968
1124
  if (err.code === 'ENOENT') {
969
1125
  log(chalk.yellow(`"${cmd}" not found. Install it with: ${providerConfig.installHint}`))
@@ -1061,18 +1217,14 @@ function connect() {
1061
1217
  if (mcpRegistered) mcpConfigPath = 'registered' // flag to skip re-registration
1062
1218
  }
1063
1219
 
1064
- // Banner
1065
- console.log('')
1066
- console.log(chalk.bold.cyan(' ╔══════════════════════════════════════╗'))
1067
- console.log(chalk.bold.cyan(' ║ Clocked in to Virtual Office ║'))
1068
- console.log(chalk.bold.cyan(' ╚══════════════════════════════════════╝'))
1069
- console.log(chalk.dim(` Agent: ${argv.agent}`))
1070
- console.log(chalk.dim(` Provider: ${argv.provider}`))
1071
- console.log(chalk.dim(` Workspace: ${workspace}`))
1072
- console.log(chalk.dim(` Identity: ${cachedSystemPrompt ? 'loaded' : 'default'}`))
1073
- console.log(chalk.dim(` Tools: ${mcpRegistered ? 'VO MCP registered ✓' : 'basic only'}`))
1074
- console.log(chalk.dim(` Press Ctrl+C to clock out`))
1075
- console.log('')
1220
+ // Banner — use the printClockInBanner from onboarding for consistent look
1221
+ const { printClockInBanner: printBanner } = await import('./onboarding.js')
1222
+ printBanner({
1223
+ agentHandle: argv.agent,
1224
+ model: argv.provider,
1225
+ seat: null,
1226
+ workspace,
1227
+ })
1076
1228
  })
1077
1229
 
1078
1230
  ws.on('message', (data) => {
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env node
2
+ // ═══════════════════════════════════════════════════════════════════════════════
3
+ // MCP Server Lite — Lightweight MCP server for the @office-xyz/claude-code npm package
4
+ //
5
+ // This is a self-contained MCP server that:
6
+ // 1. Fetches tool schemas from Chat Bridge at startup
7
+ // 2. Proxies all tool calls to Chat Bridge / Registry via HTTP
8
+ // 3. Requires zero monorepo dependencies — works with Node.js 18+ built-in fetch
9
+ //
10
+ // Used by the npm package instead of the full skyoffice-mcp-server.js which
11
+ // requires 150+ files from the monorepo.
12
+ // ═══════════════════════════════════════════════════════════════════════════════
13
+
14
+ const readline = require('readline')
15
+
16
+ // ── Config from environment ──────────────────────────────────────────────────
17
+
18
+ const CHAT_BRIDGE_URL = process.env.CHAT_BRIDGE_URL || process.env.CHAT_BRIDGE_BASE_URL || 'https://chatbridge.aladdinagi.xyz'
19
+ const CANONICAL_AGENT_HANDLE = process.env.CANONICAL_AGENT_HANDLE || null
20
+ const REGISTRY_OFFICE_ID = process.env.REGISTRY_OFFICE_ID || null
21
+ const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT || process.cwd()
22
+
23
+ function log(...args) {
24
+ console.error(`[mcp-lite] ${new Date().toISOString()}`, ...args)
25
+ }
26
+
27
+ // ── HTTP helpers ─────────────────────────────────────────────────────────────
28
+
29
+ const HTTP_TIMEOUT_MS = 30_000
30
+
31
+ async function fetchJSON(url, options = {}) {
32
+ const controller = new AbortController()
33
+ const timeout = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS)
34
+ try {
35
+ const response = await fetch(url, {
36
+ ...options,
37
+ signal: controller.signal,
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ 'Accept': 'application/json',
41
+ ...(options.headers || {}),
42
+ },
43
+ })
44
+ if (!response.ok) {
45
+ const text = await response.text().catch(() => '')
46
+ throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`)
47
+ }
48
+ return response.json()
49
+ } catch (err) {
50
+ if (err.name === 'AbortError') {
51
+ throw new Error(`Request timed out after ${HTTP_TIMEOUT_MS / 1000}s: ${url}`)
52
+ }
53
+ throw err
54
+ } finally {
55
+ clearTimeout(timeout)
56
+ }
57
+ }
58
+
59
+ // ── Tool schema loading ──────────────────────────────────────────────────────
60
+
61
+ let cachedTools = null
62
+
63
+ async function loadToolSchemas() {
64
+ if (cachedTools) return cachedTools
65
+
66
+ if (!CANONICAL_AGENT_HANDLE) {
67
+ log('WARNING: No agent handle — returning empty tool list')
68
+ cachedTools = []
69
+ return cachedTools
70
+ }
71
+
72
+ try {
73
+ const url = `${CHAT_BRIDGE_URL}/api/cli/mcp-tools/${encodeURIComponent(CANONICAL_AGENT_HANDLE)}`
74
+ log(`Fetching tool schemas from ${url}`)
75
+ const data = await fetchJSON(url)
76
+
77
+ if (data?.success && Array.isArray(data.tools)) {
78
+ cachedTools = data.tools
79
+ log(`Loaded ${cachedTools.length} tool schemas from Chat Bridge`)
80
+ return cachedTools
81
+ }
82
+ } catch (err) {
83
+ log(`Failed to fetch tool schemas: ${err.message}`)
84
+ }
85
+
86
+ // Fallback: return empty (tools will fail gracefully)
87
+ log('WARNING: Using empty tool list — tool calls will be proxied but may fail')
88
+ cachedTools = []
89
+ return cachedTools
90
+ }
91
+
92
+ // ── Tool call routing ────────────────────────────────────────────────────────
93
+ //
94
+ // Most tools follow: POST /api/{handle}/tools/{kebab-name}
95
+ // Special tools have custom URL patterns mapped below.
96
+
97
+ function toKebab(snakeName) {
98
+ return snakeName.replace(/_/g, '-')
99
+ }
100
+
101
+ // Tools with non-standard URL patterns
102
+ const SPECIAL_ROUTES = {
103
+ // SkyOffice endpoints (no /tools/ prefix)
104
+ get_skyoffice_location: { method: 'GET', path: (h) => `/api/${h}/skyoffice/location` },
105
+ list_skyoffice_seats: { method: 'GET', path: (h) => `/api/${h}/skyoffice/seats` },
106
+ list_skyoffice_rooms: { method: 'GET', path: (h) => `/api/${h}/skyoffice/rooms` },
107
+
108
+ // Office auth (different path)
109
+ office_auth_status: { method: 'GET', path: (h) => `/api/${h}/services/available` },
110
+
111
+ // SkyOffice chat (no agent handle, uses args for IDs)
112
+ react_to_message: { method: 'POST', path: (h, a) => `/api/skyoffice/messages/${enc(a.messageId)}/reactions` },
113
+ reply_in_thread: { method: 'POST', path: (h, a) => `/api/skyoffice/messages/${enc(a.parentMessageId)}/thread` },
114
+ get_thread_messages: { method: 'GET', path: (h, a) => `/api/skyoffice/messages/${enc(a.parentMessageId)}/thread` },
115
+ send_channel_message: { method: 'POST', path: (h, a) => `/api/skyoffice/channels/${enc(a.channelId)}/messages` },
116
+ get_channel_messages: { method: 'GET', path: (h, a) => `/api/skyoffice/channels/${enc(a.channelId)}/messages?limit=${Math.min(a.limit || 50, 200)}` },
117
+ create_channel: { method: 'POST', path: () => `/api/skyoffice/channels` },
118
+ pin_message: { method: 'POST', path: (h, a) => `/api/skyoffice/messages/${enc(a.messageId)}/pin` },
119
+ search_chat_messages: { method: 'GET', path: (h, a) => `/api/skyoffice/messages/search?officeId=${enc(REGISTRY_OFFICE_ID)}&q=${enc(a.query || '')}&limit=${Math.min(a.limit || 20, 100)}${a.channelId ? `&channelId=${enc(a.channelId)}` : ''}` },
120
+
121
+ // Meetings (no agent handle in URL)
122
+ join_meeting: { method: 'POST', path: () => `/api/meetings/join` },
123
+ leave_meeting: { method: 'POST', path: () => `/api/meetings/leave` },
124
+ get_meeting_transcript: { method: 'GET', path: (h, a) => `/api/meetings/${enc(a.meetingId)}/transcript` },
125
+ generate_meeting_notes: { method: 'POST', path: (h, a) => `/api/meetings/${enc(a.meetingId)}/notes/generate` },
126
+ list_meetings: { method: 'GET', path: () => `/api/meetings` },
127
+ get_meeting_notes: { method: 'GET', path: (h, a) => `/api/meetings/${enc(a.meetingId)}/notes` },
128
+ distribute_meeting_notes: { method: 'POST', path: (h, a) => `/api/meetings/${enc(a.meetingId)}/notes/distribute` },
129
+ speak_in_meeting: { method: 'POST', path: (h, a) => `/api/meetings/${enc(a.meetingId)}/speak` },
130
+
131
+ // Google auth (GET instead of POST)
132
+ list_connected_google_accounts: { method: 'GET', path: (h) => `/api/${h}/tools/list-connected-google-accounts` },
133
+ list_connected_microsoft_accounts: { method: 'GET', path: (h) => `/api/${h}/tools/list-connected-microsoft-accounts` },
134
+ }
135
+
136
+ function enc(v) { return encodeURIComponent(v || '') }
137
+
138
+ // ── Custom tool handlers ─────────────────────────────────────────────────────
139
+ // Tools that need officeId or have multi-step logic can't use simple routing.
140
+
141
+ const CUSTOM_HANDLERS = {
142
+ // File management → /api/offices/{officeId}/files
143
+ list_files: async (args) => {
144
+ const o = enc(REGISTRY_OFFICE_ID)
145
+ const params = new URLSearchParams()
146
+ if (args.prefix) params.append('prefix', args.prefix)
147
+ if (args.maxKeys) params.append('maxKeys', String(args.maxKeys))
148
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/files?${params}`)
149
+ },
150
+ get_file: async (args) => {
151
+ const o = enc(REGISTRY_OFFICE_ID)
152
+ const qs = args.metadataOnly ? '?metadataOnly=true' : ''
153
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/files/${args.filePath}${qs}`)
154
+ },
155
+ read_document: async (args) => {
156
+ const o = enc(REGISTRY_OFFICE_ID)
157
+ const fp = args.path || args.filePath
158
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/files/${fp}?parseContent=true`)
159
+ },
160
+ delete_file: async (args) => {
161
+ const o = enc(REGISTRY_OFFICE_ID)
162
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/files/${args.filePath}`, { method: 'DELETE' })
163
+ },
164
+ upload_file: async (args) => {
165
+ const o = enc(REGISTRY_OFFICE_ID)
166
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/files`, {
167
+ method: 'POST',
168
+ body: JSON.stringify(args),
169
+ })
170
+ },
171
+
172
+ // Draft management → /api/offices/{officeId}/drafts
173
+ create_draft: async (args) => {
174
+ const o = enc(REGISTRY_OFFICE_ID)
175
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/drafts`, {
176
+ method: 'POST',
177
+ body: JSON.stringify({ agentHandle: CANONICAL_AGENT_HANDLE, ...args }),
178
+ })
179
+ },
180
+ save_draft: async (args) => {
181
+ const o = enc(REGISTRY_OFFICE_ID)
182
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/drafts/${enc(args.draftId)}`, {
183
+ method: 'PATCH',
184
+ body: JSON.stringify({ content: args.content, agentHandle: CANONICAL_AGENT_HANDLE }),
185
+ })
186
+ },
187
+ submit_draft: async (args) => {
188
+ const o = enc(REGISTRY_OFFICE_ID)
189
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/drafts/${enc(args.draftId)}/submit`, { method: 'POST' })
190
+ },
191
+ get_draft: async (args) => {
192
+ const o = enc(REGISTRY_OFFICE_ID)
193
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/drafts/${enc(args.draftId)}`)
194
+ },
195
+ list_drafts: async (args) => {
196
+ const o = enc(REGISTRY_OFFICE_ID)
197
+ const params = new URLSearchParams()
198
+ if (CANONICAL_AGENT_HANDLE) params.set('agentHandle', CANONICAL_AGENT_HANDLE)
199
+ if (args.status) params.set('status', args.status)
200
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/drafts?${params}`)
201
+ },
202
+ discard_draft: async (args) => {
203
+ const o = enc(REGISTRY_OFFICE_ID)
204
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/drafts/${enc(args.draftId)}/discard`, { method: 'POST' })
205
+ },
206
+
207
+ // Task management → /api/offices/{officeId}/tasks
208
+ create_task: async (args) => {
209
+ const o = enc(REGISTRY_OFFICE_ID)
210
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks`, {
211
+ method: 'POST',
212
+ body: JSON.stringify({
213
+ title: args.title,
214
+ description: args.description || '',
215
+ priority: args.priority || 'medium',
216
+ executionMode: args.executionMode || 'agent',
217
+ createdBy: CANONICAL_AGENT_HANDLE || 'agent',
218
+ assigneeIds: args.assigneeIds || [],
219
+ contextFiles: args.contextFiles || [],
220
+ }),
221
+ })
222
+ },
223
+ batch_create_tasks: async (args) => {
224
+ const o = enc(REGISTRY_OFFICE_ID)
225
+ const results = { created: [], failed: [] }
226
+ for (const t of (args.tasks || [])) {
227
+ try {
228
+ const r = await fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks`, {
229
+ method: 'POST',
230
+ body: JSON.stringify({
231
+ title: t.title, description: t.description || '',
232
+ priority: t.priority || args.defaultPriority || 'medium',
233
+ executionMode: t.executionMode || args.defaultExecutionMode || 'agent',
234
+ createdBy: CANONICAL_AGENT_HANDLE || 'agent',
235
+ assigneeIds: t.assigneeIds || [],
236
+ }),
237
+ })
238
+ if (r.success && r.data) results.created.push({ id: r.data.id, title: t.title })
239
+ else results.failed.push({ title: t.title, error: r.error || 'Unknown' })
240
+ } catch (e) { results.failed.push({ title: t.title, error: e.message }) }
241
+ }
242
+ return { success: results.created.length > 0, ...results, summary: `Created ${results.created.length}/${args.tasks?.length || 0} tasks` }
243
+ },
244
+ assign_task: async (args) => {
245
+ const o = enc(REGISTRY_OFFICE_ID)
246
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks/${enc(args.taskId)}`, {
247
+ method: 'PATCH',
248
+ body: JSON.stringify({ assigneeIds: args.agentHandles }),
249
+ })
250
+ },
251
+ list_available_tasks: async (args) => {
252
+ const o = enc(REGISTRY_OFFICE_ID)
253
+ const params = new URLSearchParams({ unassigned: 'true', limit: String(args.limit || 10) })
254
+ if (args.status) params.append('status', args.status)
255
+ if (args.priority) params.append('priority', args.priority)
256
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks?${params}`)
257
+ },
258
+ list_my_tasks: async (args) => {
259
+ const o = enc(REGISTRY_OFFICE_ID)
260
+ const params = new URLSearchParams({ assigneeId: CANONICAL_AGENT_HANDLE, limit: '20' })
261
+ if (args.status) params.append('status', args.status)
262
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks?${params}`)
263
+ },
264
+ get_task_details: async (args) => {
265
+ const o = enc(REGISTRY_OFFICE_ID)
266
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks/${enc(args.taskId)}`)
267
+ },
268
+ claim_task: async (args) => {
269
+ const o = enc(REGISTRY_OFFICE_ID)
270
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks/${enc(args.taskId)}/claim`, {
271
+ method: 'POST',
272
+ body: JSON.stringify({ agentHandle: CANONICAL_AGENT_HANDLE, agentLabel: CANONICAL_AGENT_HANDLE?.split('.')[0] }),
273
+ })
274
+ },
275
+ unclaim_task: async (args) => {
276
+ const o = enc(REGISTRY_OFFICE_ID)
277
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks/${enc(args.taskId)}/unclaim`, {
278
+ method: 'POST',
279
+ body: JSON.stringify({ agentHandle: CANONICAL_AGENT_HANDLE }),
280
+ })
281
+ },
282
+ update_task_progress: async (args) => {
283
+ const o = enc(REGISTRY_OFFICE_ID)
284
+ const r = await fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks/${enc(args.taskId)}/outputs`, {
285
+ method: 'POST',
286
+ body: JSON.stringify({ type: 'result', content: args.notes, agentId: CANONICAL_AGENT_HANDLE, agentName: CANONICAL_AGENT_HANDLE?.split('.')[0] || 'Agent' }),
287
+ })
288
+ if (args.status) {
289
+ await fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks/${enc(args.taskId)}`, {
290
+ method: 'PATCH', body: JSON.stringify({ status: args.status }),
291
+ }).catch(() => {})
292
+ }
293
+ return r
294
+ },
295
+ complete_task: async (args) => {
296
+ const o = enc(REGISTRY_OFFICE_ID)
297
+ if (args.notes) {
298
+ const note = `✅ COMPLETED by ${CANONICAL_AGENT_HANDLE || 'Agent'}\n\n${args.notes}${args.artifacts?.length ? `\n\nArtifacts:\n${args.artifacts.map(a => `• ${a}`).join('\n')}` : ''}`
299
+ await fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks/${enc(args.taskId)}/outputs`, {
300
+ method: 'POST',
301
+ body: JSON.stringify({ type: 'result', content: note, agentId: CANONICAL_AGENT_HANDLE }),
302
+ }).catch(() => {})
303
+ }
304
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/offices/${o}/tasks/${enc(args.taskId)}`, {
305
+ method: 'PATCH', body: JSON.stringify({ status: 'done' }),
306
+ })
307
+ },
308
+
309
+ // Spawn task session → /api/{agentId}/conversations
310
+ spawn_task_session: async (args) => {
311
+ const h = enc(args.agentId || CANONICAL_AGENT_HANDLE)
312
+ return fetchJSON(`${CHAT_BRIDGE_URL}/api/${h}/conversations`, {
313
+ method: 'POST',
314
+ body: JSON.stringify({
315
+ taskId: args.taskId, projectPath: args.projectPath,
316
+ branchName: args.branchName, provider: args.provider,
317
+ modelId: args.modelId, systemPromptAddition: args.systemPromptAddition,
318
+ }),
319
+ })
320
+ },
321
+ batch_spawn_task_sessions: async (args) => {
322
+ const results = { spawned: [], failed: [] }
323
+ for (const s of (args.sessions || [])) {
324
+ try {
325
+ const h = enc(s.agentId || CANONICAL_AGENT_HANDLE)
326
+ const r = await fetchJSON(`${CHAT_BRIDGE_URL}/api/${h}/conversations`, {
327
+ method: 'POST',
328
+ body: JSON.stringify({ taskId: s.taskId, provider: s.provider || args.defaultProvider, modelId: s.modelId, systemPromptAddition: s.systemPromptAddition }),
329
+ })
330
+ results.spawned.push({ taskId: s.taskId, agentId: s.agentId, ...r })
331
+ } catch (e) { results.failed.push({ taskId: s.taskId, error: e.message }) }
332
+ }
333
+ return { success: results.spawned.length > 0, ...results }
334
+ },
335
+ }
336
+
337
+ async function callTool(name, args) {
338
+ const handle = CANONICAL_AGENT_HANDLE
339
+ if (!handle) return { success: false, error: 'Agent handle not configured' }
340
+ const h = encodeURIComponent(handle)
341
+
342
+ try {
343
+ // 1. Check custom handlers (file/task/draft tools with complex logic)
344
+ const custom = CUSTOM_HANDLERS[name]
345
+ if (custom) {
346
+ if (!REGISTRY_OFFICE_ID && name !== 'spawn_task_session' && name !== 'batch_spawn_task_sessions') {
347
+ return { success: false, error: 'Office ID not configured' }
348
+ }
349
+ log(`[call] ${name} → custom handler`)
350
+ return await custom(args || {})
351
+ }
352
+
353
+ // 2. Check special routes (non-standard URL patterns)
354
+ const special = SPECIAL_ROUTES[name]
355
+ let method, urlPath
356
+ if (special) {
357
+ method = special.method
358
+ urlPath = special.path(h, args)
359
+ } else {
360
+ // 3. Default pattern: POST /api/{handle}/tools/{kebab-name}
361
+ method = 'POST'
362
+ urlPath = `/api/${h}/tools/${toKebab(name)}`
363
+ }
364
+
365
+ const url = `${CHAT_BRIDGE_URL}${urlPath}`
366
+ log(`[call] ${name} → ${method} ${urlPath}`)
367
+
368
+ const options = method === 'GET'
369
+ ? { method: 'GET' }
370
+ : { method, body: JSON.stringify(args || {}) }
371
+
372
+ return await fetchJSON(url, options)
373
+ } catch (err) {
374
+ log(`[call] ${name} failed:`, err.message)
375
+ return { success: false, error: err.message }
376
+ }
377
+ }
378
+
379
+ // ── MCP JSON-RPC protocol ────────────────────────────────────────────────────
380
+
381
+ class McpServer {
382
+ constructor() {
383
+ this.toolsLoaded = false
384
+ this.tools = []
385
+ }
386
+
387
+ async ensureToolsLoaded() {
388
+ if (!this.toolsLoaded) {
389
+ this.tools = await loadToolSchemas()
390
+ this.toolsLoaded = true
391
+ }
392
+ }
393
+
394
+ async handleRequest(request) {
395
+ const { method, params, id } = request
396
+
397
+ switch (method) {
398
+ case 'initialize':
399
+ return {
400
+ jsonrpc: '2.0',
401
+ id,
402
+ result: {
403
+ protocolVersion: '2024-11-05',
404
+ capabilities: { tools: {} },
405
+ serverInfo: { name: 'vo-mcp-lite', version: '1.0.0' },
406
+ },
407
+ }
408
+
409
+ case 'tools/list':
410
+ await this.ensureToolsLoaded()
411
+ return {
412
+ jsonrpc: '2.0',
413
+ id,
414
+ result: {
415
+ tools: this.tools.map(t => ({
416
+ name: t.name,
417
+ description: t.description,
418
+ inputSchema: t.inputSchema,
419
+ })),
420
+ },
421
+ }
422
+
423
+ case 'tools/call': {
424
+ const { name, arguments: toolArgs } = params || {}
425
+ if (!name) return this.error(id, -32602, 'Tool name is required')
426
+
427
+ log(`Tool call: ${name}`)
428
+ const result = await callTool(name, toolArgs || {})
429
+ return {
430
+ jsonrpc: '2.0',
431
+ id,
432
+ result: {
433
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
434
+ },
435
+ }
436
+ }
437
+
438
+ case 'notifications/initialized':
439
+ return null
440
+
441
+ default:
442
+ return this.error(id, -32601, `Method not found: ${method}`)
443
+ }
444
+ }
445
+
446
+ error(id, code, message) {
447
+ return { jsonrpc: '2.0', id, error: { code, message } }
448
+ }
449
+ }
450
+
451
+ // ── Main ─────────────────────────────────────────────────────────────────────
452
+
453
+ async function main() {
454
+ log('Starting VO MCP Lite v1.0.0')
455
+ log('Chat Bridge:', CHAT_BRIDGE_URL)
456
+ log('Agent:', CANONICAL_AGENT_HANDLE || '(not set)')
457
+ log('Office:', REGISTRY_OFFICE_ID || '(not set)')
458
+
459
+ const server = new McpServer()
460
+
461
+ const rl = readline.createInterface({
462
+ input: process.stdin,
463
+ output: process.stdout,
464
+ terminal: false,
465
+ })
466
+
467
+ rl.on('line', async (line) => {
468
+ if (!line.trim()) return
469
+ try {
470
+ const request = JSON.parse(line)
471
+ const response = await server.handleRequest(request)
472
+ if (response) console.log(JSON.stringify(response))
473
+ } catch (error) {
474
+ log('Parse error:', error.message)
475
+ console.log(JSON.stringify({
476
+ jsonrpc: '2.0',
477
+ id: null,
478
+ error: { code: -32700, message: 'Parse error' },
479
+ }))
480
+ }
481
+ })
482
+
483
+ rl.on('close', () => {
484
+ log('Connection closed')
485
+ process.exit(0)
486
+ })
487
+ }
488
+
489
+ main().catch(error => {
490
+ log('Fatal error:', error)
491
+ process.exit(1)
492
+ })
package/onboarding.js CHANGED
@@ -125,38 +125,67 @@ export async function checkForUpdate() {
125
125
  }
126
126
  }
127
127
 
128
+ // ── Adam Sprite Art (auto-generated from pixel art sprite sheet) ──────────
129
+ // Half-block rendering: 2x2 pixels → 1 char, truecolor ANSI
130
+ import { ADAM_IDLE_FRAMES, ADAM_SIT_FRAME, ADAM_FRAMES } from './adam-frames.js'
131
+
128
132
  // ── Banner ────────────────────────────────────────────────────────────────
129
133
 
130
134
  export function printBanner(subtitle = 'Manage Your AI Agents') {
135
+ const frame = ADAM_IDLE_FRAMES[0]
136
+
137
+ // Text lines aligned to Adam sprite rows (~23 rows)
138
+ // Adam is ~32 chars wide; text appears to the right with padding
139
+ const textLines = [
140
+ '',
141
+ '',
142
+ '',
143
+ '',
144
+ '',
145
+ '',
146
+ '',
147
+ '',
148
+ chalk.bold.white(' Virtual Office'),
149
+ chalk.dim(` ${subtitle}`),
150
+ '',
151
+ chalk.dim(' office.xyz'),
152
+ ]
153
+
131
154
  console.log('')
132
- console.log(chalk.cyan(' ┌─────────────────────────────────────────┐'))
133
- console.log(chalk.cyan(' ') + ' ' + chalk.cyan(''))
134
- console.log(chalk.cyan(' │') + chalk.bold.white(' ▓▓▓') + ' ' + chalk.cyan('│'))
135
- console.log(chalk.cyan(' │') + chalk.bold.white(' ▓░░▓') + chalk.bold.white(' Virtual Office') + ' ' + chalk.cyan('│'))
136
- console.log(chalk.cyan(' │') + chalk.bold.white(' ▓▓▓▓') + chalk.dim(` ${subtitle}`) + ' ' + chalk.cyan('│'))
137
- console.log(chalk.cyan(' │') + chalk.bold.white(' ██') + ' ' + chalk.cyan('│'))
138
- console.log(chalk.cyan(' │') + chalk.dim(' ▓▓▓▓ office.xyz') + ' ' + chalk.cyan('│'))
139
- console.log(chalk.cyan(' │') + ' ' + chalk.cyan('│'))
140
- console.log(chalk.cyan(' └─────────────────────────────────────────┘'))
155
+ for (let i = 0; i < frame.length; i++) {
156
+ console.log(' ' + frame[i] + (textLines[i] || ''))
157
+ }
141
158
  console.log('')
142
159
  }
143
160
 
144
161
  export function printClockInBanner({ agentHandle, model, seat, workspace }) {
162
+ const frame = ADAM_SIT_FRAME
163
+
164
+ // Right-side info lines aligned to Adam's sitting sprite (~23 rows)
165
+ const infoLines = [
166
+ '',
167
+ '',
168
+ '',
169
+ '',
170
+ '',
171
+ chalk.green.bold(' ✓ Clocked in to Virtual Office'),
172
+ '',
173
+ chalk.dim(' Agent: ') + chalk.bold.white(agentHandle),
174
+ chalk.dim(' Model: ') + chalk.white(model || 'Claude Opus 4.6'),
175
+ seat ? (chalk.dim(' Seat: ') + chalk.white(seat)) : '',
176
+ chalk.dim(' Dir: ') + chalk.white(workspace || process.cwd()),
177
+ '',
178
+ chalk.dim(' Web: ') + chalk.underline.cyan('https://beta.office.xyz'),
179
+ '',
180
+ chalk.dim(' Press ') + chalk.yellow('Ctrl+C') + chalk.dim(' to clock out'),
181
+ ]
182
+
145
183
  console.log('')
146
- console.log(chalk.cyan(' ┌─────────────────────────────────────────┐'))
147
- console.log(chalk.cyan(' │') + chalk.green.bold(' ✓ Clocked in to Virtual Office') + ' ' + chalk.cyan(''))
148
- console.log(chalk.cyan(' │') + ' ' + chalk.cyan(''))
149
- console.log(chalk.cyan(' ') + chalk.dim(' Agent: ') + chalk.bold.white(agentHandle))
150
- console.log(chalk.cyan(' │') + chalk.dim(' Model: ') + chalk.white(model || 'Claude Opus 4.6'))
151
- if (seat) {
152
- console.log(chalk.cyan(' │') + chalk.dim(' Seat: ') + chalk.white(seat))
184
+ for (let i = 0; i < Math.max(frame.length, infoLines.length); i++) {
185
+ const sprite = (i < frame.length) ? frame[i] : ''
186
+ const info = (i < infoLines.length) ? infoLines[i] : ''
187
+ console.log(' ' + sprite + info)
153
188
  }
154
- console.log(chalk.cyan(' │') + chalk.dim(' Workspace: ') + chalk.white(workspace || process.cwd()))
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('│'))
158
- console.log(chalk.cyan(' │') + chalk.yellow(' Press Ctrl+C to clock out') + ' ' + chalk.cyan('│'))
159
- console.log(chalk.cyan(' └─────────────────────────────────────────┘'))
160
189
  console.log('')
161
190
  }
162
191
 
@@ -510,19 +539,54 @@ export async function runOnboarding() {
510
539
  // 1. Check cached session
511
540
  const cached = loadSession()
512
541
 
513
- if (cached?.sessionToken && cached?.lastAgent?.handle && cached?.lastAgent?.connectionToken) {
514
- // Quick reconnect path
542
+ if (cached?.sessionToken && cached?.lastAgent?.handle) {
543
+ // Quick reconnect path — always refresh token to avoid stale token rejection
515
544
  const spinner = ora('Validating session...').start()
516
545
  try {
517
546
  const validation = await api('GET', '/api/cli/auth/session', null, cached.sessionToken)
518
547
  if (validation.success) {
548
+ spinner.text = 'Refreshing connection...'
549
+
550
+ // Re-hire with same agent name to get a fresh connectionToken.
551
+ // This is idempotent — if agent exists, it just refreshes the token.
552
+ const agentName = cached.lastAgent.handle.split('.')[0]
553
+ const officeId = cached.lastOfficeId || null
554
+
555
+ let freshToken = cached.lastAgent.connectionToken
556
+ let seat = cached.lastAgent.seat
557
+
558
+ if (agentName && officeId) {
559
+ try {
560
+ const result = await api('POST', '/api/cli/office/hire', {
561
+ officeId,
562
+ agentName,
563
+ provider: 'claude-code',
564
+ }, cached.sessionToken)
565
+ freshToken = result.connectionToken || freshToken
566
+ seat = result.seat || seat
567
+
568
+ // Update cached session with fresh token
569
+ saveSession({
570
+ ...cached,
571
+ lastAgent: {
572
+ ...cached.lastAgent,
573
+ connectionToken: freshToken,
574
+ seat,
575
+ },
576
+ })
577
+ } catch (err) {
578
+ // If refresh fails, try with cached token anyway
579
+ console.log(chalk.dim(` Token refresh failed (${err.message}), using cached token`))
580
+ }
581
+ }
582
+
519
583
  spinner.succeed(`Welcome back, ${chalk.bold(cached.email || cached.displayName || 'user')}!`)
520
584
  console.log(chalk.dim(` Reconnecting as ${cached.lastAgent.handle}...`))
521
585
  console.log(chalk.dim(` Web interface: ${chalk.underline.cyan('https://beta.office.xyz')}`))
522
586
  return {
523
587
  agent: cached.lastAgent.handle,
524
- token: cached.lastAgent.connectionToken,
525
- seat: cached.lastAgent.seat,
588
+ token: freshToken,
589
+ seat,
526
590
  }
527
591
  }
528
592
  } catch {
@@ -548,6 +612,7 @@ export async function runOnboarding() {
548
612
  sessionToken: session.sessionToken,
549
613
  expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
550
614
  lastOffice: domain,
615
+ lastOfficeId: officeId,
551
616
  lastAgent: {
552
617
  handle: hired.agentHandle,
553
618
  connectionToken: hired.connectionToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@office-xyz/claude-code",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Connect Claude Code to Office.xyz — a shared working environment for all your AI agents, cloud and local",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "index.js",
13
13
  "onboarding.js",
14
+ "mcp-server-lite.cjs",
14
15
  "README.md",
15
16
  "LICENSE",
16
17
  "CHANGELOG.md"