@lightcone-ai/daemon 0.6.4 → 0.6.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -3,6 +3,8 @@ import { mkdirSync, readdirSync, readFileSync, statSync, lstatSync } from 'fs';
3
3
  import { homedir } from 'os';
4
4
  import path from 'path';
5
5
  import { buildSystemPrompt } from './drivers/claude.js';
6
+ import { buildCodexSpawn, buildCodexSystemPrompt, parseCodexLine } from './drivers/codex.js';
7
+ import { buildKimiSpawn, buildKimiInitMessages, parseKimiLine, encodeKimiStdin } from './drivers/kimi.js';
6
8
 
7
9
  export class AgentManager {
8
10
  constructor({ serverUrl, machineApiKey }) {
@@ -47,6 +49,19 @@ export class AgentManager {
47
49
  return dir;
48
50
  }
49
51
 
52
+ _formatDeliveryText(message) {
53
+ return `New message in ${message.channel_type === 'dm' ? 'dm from' : `#${message.channel_name} from`} ${message.sender_name}: ${message.content}`;
54
+ }
55
+
56
+ _takePendingMessage(key) {
57
+ if (!this._pendingMessages) return null;
58
+ const pending = this._pendingMessages.get(key);
59
+ if (!pending?.length) return null;
60
+ const msg = pending.shift();
61
+ if (pending.length === 0) this._pendingMessages.delete(key);
62
+ return msg;
63
+ }
64
+
50
65
  _startAgent({ agentId, channelId, config }, connection) {
51
66
  const key = this._key(agentId, channelId);
52
67
  if (this.agents.has(key) || this.starting.has(key)) {
@@ -55,88 +70,184 @@ export class AgentManager {
55
70
  }
56
71
  this.starting.add(key);
57
72
 
73
+ const runtime = config.runtime ?? 'claude';
58
74
  const workspaceDir = this._workspaceDir(agentId, channelId);
59
75
  const chatBridgePath = new URL('./chat-bridge.js', import.meta.url).pathname;
76
+ const startupMsg = runtime === 'codex' ? this._takePendingMessage(key) : null;
60
77
 
61
- const mcpServers = {
62
- chat: {
63
- command: 'node',
64
- args: [chatBridgePath],
65
- env: {
66
- SERVER_URL: this.serverUrl,
67
- MACHINE_API_KEY: this.machineApiKey,
68
- AGENT_ID: agentId,
69
- CHANNEL_ID: channelId ?? '',
70
- },
71
- },
72
- };
78
+ let proc;
73
79
 
74
- if (config.browserAccess) {
75
- mcpServers['chrome-devtools'] = {
76
- command: 'npx',
77
- args: ['chrome-devtools-mcp@latest', '--headless'],
78
- env: {},
79
- };
80
- }
80
+ if (runtime === 'kimi') {
81
+ // ── Kimi CLI ──────────────────────────────────────────────────────────
82
+ const kimiSpawn = buildKimiSpawn({
83
+ config, agentId, channelId, workspaceDir, chatBridgePath,
84
+ serverUrl: this.serverUrl, machineApiKey: this.machineApiKey,
85
+ });
86
+
87
+ console.log(`[AgentManager] Spawning kimi for ${agentId} channel=${channelId ?? 'none'} (session=${kimiSpawn.sessionId})`);
88
+
89
+ proc = spawn('kimi', kimiSpawn.args, {
90
+ cwd: workspaceDir,
91
+ env: kimiSpawn.env,
92
+ stdio: ['pipe', 'pipe', 'pipe'],
93
+ });
94
+
95
+ // Send initialize + prompt via stdin
96
+ const initMsgs = buildKimiInitMessages(kimiSpawn);
97
+ for (const msg of initMsgs) {
98
+ proc.stdin.write(msg + '\n');
99
+ }
100
+
101
+ // Track Kimi-specific state for parsing
102
+ const kimiState = { sessionId: kimiSpawn.sessionId, sessionAnnounced: false };
103
+
104
+ this.agents.set(key, {
105
+ config, channelId, agentId, sessionId: kimiSpawn.sessionId, proc,
106
+ runtime: 'kimi', kimiState, kimiIdle: false,
107
+ });
108
+ this.starting.delete(key);
109
+
110
+ let buffer = '';
111
+ proc.stdout.on('data', (chunk) => {
112
+ buffer += chunk.toString();
113
+ const lines = buffer.split('\n');
114
+ buffer = lines.pop();
115
+ for (const line of lines) {
116
+ if (!line.trim()) continue;
117
+ this._parseKimiLine(key, agentId, channelId, line, connection);
118
+ }
119
+ });
120
+ } else if (runtime === 'codex') {
121
+ const startupPrompt = startupMsg
122
+ ? `${buildCodexSystemPrompt(config, agentId)}\n\nNew message received:\n\n${this._formatDeliveryText(startupMsg.message)}\n\nRespond as appropriate. Complete all required work before stopping.`
123
+ : buildCodexSystemPrompt(config, agentId);
124
+
125
+ const codexSpawn = buildCodexSpawn({
126
+ config,
127
+ agentId,
128
+ channelId,
129
+ workspaceDir,
130
+ chatBridgePath,
131
+ serverUrl: this.serverUrl,
132
+ machineApiKey: this.machineApiKey,
133
+ prompt: startupPrompt,
134
+ });
81
135
 
82
- if (config.mysqlAccess) {
83
- const mysqlServerPath = new URL('../../mcp-servers/mysql/index.js', import.meta.url).pathname;
84
- const agentEnv = config.envVars ?? {};
85
- mcpServers['mysql'] = {
86
- command: 'node',
87
- args: [mysqlServerPath],
88
- env: {
89
- DB_HOST: process.env.DB_HOST ?? '',
90
- DB_PORT: process.env.DB_PORT ?? '3306',
91
- DB_USER: process.env.DB_USER ?? '',
92
- DB_PASSWORD: process.env.DB_PASSWORD ?? '',
93
- DB_NAME: agentEnv.MYSQL_DB ?? process.env.DB_NAME ?? '',
136
+ console.log(`[AgentManager] Spawning codex for ${agentId} channel=${channelId ?? 'none'} (session=${config.sessionId ?? 'new'})`);
137
+
138
+ proc = spawn('codex', codexSpawn.args, {
139
+ cwd: workspaceDir,
140
+ env: codexSpawn.env,
141
+ stdio: ['ignore', 'pipe', 'pipe'],
142
+ });
143
+
144
+ this.agents.set(key, {
145
+ config, channelId, agentId, sessionId: config.sessionId ?? null, proc,
146
+ runtime: 'codex',
147
+ });
148
+ this.starting.delete(key);
149
+
150
+ let buffer = '';
151
+ proc.stdout.on('data', (chunk) => {
152
+ buffer += chunk.toString();
153
+ const lines = buffer.split('\n');
154
+ buffer = lines.pop();
155
+ for (const line of lines) {
156
+ if (!line.trim()) continue;
157
+ this._parseCodexLine(key, agentId, channelId, line, connection);
158
+ }
159
+ });
160
+ } else {
161
+ // ── Claude CLI (default) ──────────────────────────────────────────────
162
+ const mcpServers = {
163
+ chat: {
164
+ command: 'node',
165
+ args: [chatBridgePath],
166
+ env: {
167
+ SERVER_URL: this.serverUrl,
168
+ MACHINE_API_KEY: this.machineApiKey,
169
+ AGENT_ID: agentId,
170
+ CHANNEL_ID: channelId ?? '',
171
+ },
94
172
  },
95
173
  };
96
- }
97
174
 
98
- const mcpConfig = { mcpServers };
99
-
100
- const args = [
101
- '--allow-dangerously-skip-permissions',
102
- '--dangerously-skip-permissions',
103
- '--verbose',
104
- '--output-format', 'stream-json',
105
- '--input-format', 'stream-json',
106
- '--mcp-config', JSON.stringify(mcpConfig),
107
- '--system-prompt', buildSystemPrompt(config, agentId),
108
- '--disallowed-tools', 'EnterPlanMode,ExitPlanMode',
109
- ];
110
-
111
- if (config.sessionId) {
112
- args.push('--resume', config.sessionId);
113
- }
175
+ if (config.browserAccess) {
176
+ mcpServers['chrome-devtools'] = {
177
+ command: 'npx',
178
+ args: ['chrome-devtools-mcp@latest', '--headless'],
179
+ env: {},
180
+ };
181
+ }
114
182
 
115
- const spawnEnv = { ...process.env, FORCE_COLOR: '0', ...(config.envVars ?? {}) };
116
- delete spawnEnv.CLAUDECODE;
183
+ if (config.mysqlAccess) {
184
+ const mysqlServerPath = new URL('../../mcp-servers/mysql/index.js', import.meta.url).pathname;
185
+ const agentEnv = config.envVars ?? {};
186
+ mcpServers['mysql'] = {
187
+ command: 'node',
188
+ args: [mysqlServerPath],
189
+ env: {
190
+ DB_HOST: process.env.DB_HOST ?? '',
191
+ DB_PORT: process.env.DB_PORT ?? '3306',
192
+ DB_USER: process.env.DB_USER ?? '',
193
+ DB_PASSWORD: process.env.DB_PASSWORD ?? '',
194
+ DB_NAME: agentEnv.MYSQL_DB ?? process.env.DB_NAME ?? '',
195
+ },
196
+ };
197
+ }
117
198
 
118
- console.log(`[AgentManager] Spawning claude for ${agentId} channel=${channelId ?? 'none'} (session=${config.sessionId ?? 'new'})`);
199
+ const mcpConfig = { mcpServers };
200
+
201
+ const args = [
202
+ '--print',
203
+ '--allow-dangerously-skip-permissions',
204
+ '--dangerously-skip-permissions',
205
+ '--verbose',
206
+ '--output-format', 'stream-json',
207
+ '--input-format', 'stream-json',
208
+ '--mcp-config', JSON.stringify(mcpConfig),
209
+ '--system-prompt', buildSystemPrompt(config, agentId),
210
+ '--disallowed-tools', 'EnterPlanMode,ExitPlanMode',
211
+ ];
212
+
213
+ if (config.sessionId) {
214
+ // Only resume if the session file exists locally
215
+ const projectSlug = workspaceDir.replace(/[\/\.]/g, '-');
216
+ const sessionFile = path.join(homedir(), '.claude', 'projects', projectSlug, `${config.sessionId}.jsonl`);
217
+ try {
218
+ statSync(sessionFile);
219
+ args.push('--resume', config.sessionId);
220
+ } catch {
221
+ console.log(`[AgentManager] Session ${config.sessionId} not found locally, starting fresh`);
222
+ }
223
+ }
119
224
 
120
- const proc = spawn('claude', args, {
121
- cwd: workspaceDir,
122
- env: spawnEnv,
123
- stdio: ['pipe', 'pipe', 'pipe'],
124
- });
225
+ const spawnEnv = { ...process.env, FORCE_COLOR: '0', ...(config.envVars ?? {}) };
226
+ delete spawnEnv.CLAUDECODE;
125
227
 
126
- this.agents.set(key, { config, channelId, agentId, sessionId: config.sessionId ?? null, proc });
127
- this.starting.delete(key);
128
-
129
- // Parse stdout stream for session ID and activity updates
130
- let buffer = '';
131
- proc.stdout.on('data', (chunk) => {
132
- buffer += chunk.toString();
133
- const lines = buffer.split('\n');
134
- buffer = lines.pop();
135
- for (const line of lines) {
136
- if (!line.trim()) continue;
137
- this._parseLine(key, agentId, channelId, line, connection);
138
- }
139
- });
228
+ console.log(`[AgentManager] Spawning claude for ${agentId} channel=${channelId ?? 'none'} (session=${config.sessionId ?? 'new'})`);
229
+
230
+ proc = spawn('claude', args, {
231
+ cwd: workspaceDir,
232
+ env: spawnEnv,
233
+ stdio: ['pipe', 'pipe', 'pipe'],
234
+ });
235
+
236
+ this.agents.set(key, { config, channelId, agentId, sessionId: config.sessionId ?? null, proc, runtime: 'claude' });
237
+ this.starting.delete(key);
238
+
239
+ // Parse stdout stream for session ID and activity updates
240
+ let buffer = '';
241
+ proc.stdout.on('data', (chunk) => {
242
+ buffer += chunk.toString();
243
+ const lines = buffer.split('\n');
244
+ buffer = lines.pop();
245
+ for (const line of lines) {
246
+ if (!line.trim()) continue;
247
+ this._parseLine(key, agentId, channelId, line, connection);
248
+ }
249
+ });
250
+ }
140
251
 
141
252
  proc.stderr.on('data', (data) => {
142
253
  const text = data.toString().trim();
@@ -144,9 +255,16 @@ export class AgentManager {
144
255
  });
145
256
 
146
257
  proc.on('exit', (code) => {
258
+ const agent = this.agents.get(key);
147
259
  console.log(`[AgentManager] Agent ${agentId} channel=${channelId ?? 'none'} exited (code=${code})`);
148
260
  this.agents.delete(key);
149
261
 
262
+ if (code === 0 && runtime === 'codex' && this._pendingMessages?.get(key)?.length) {
263
+ const restartConfig = { ...config, sessionId: agent?.sessionId ?? config.sessionId ?? null };
264
+ this._startAgent({ agentId, channelId, config: restartConfig }, connection);
265
+ return;
266
+ }
267
+
150
268
  // If exited immediately with an error and had a session, retry without session (session file may not exist on this machine)
151
269
  if (code !== 0 && config.sessionId && !this._retried?.has(key)) {
152
270
  if (!this._retried) this._retried = new Set();
@@ -161,8 +279,12 @@ export class AgentManager {
161
279
  connection.send({ type: 'agent:activity', agentId, channelId, activity: 'offline', detail: '', entries: [] });
162
280
  });
163
281
 
164
- // Send startup prompt — agent reads memory index first, then checks messages
165
- this._write(key, 'You have just started. Follow your startup sequence: first call read_memory with path="MEMORY.md" to load your memory index, then call check_messages.');
282
+ // Send startup prompt
283
+ if (runtime === 'kimi' || runtime === 'codex') {
284
+ // Kimi already received its prompt via the initialize + prompt JSON-RPC messages
285
+ } else {
286
+ this._write(key, 'You have just started. Follow your startup sequence: first call read_memory with path="MEMORY.md" to load your memory index, then call check_messages.');
287
+ }
166
288
 
167
289
  connection.send({ type: 'agent:status', agentId, channelId, status: 'active' });
168
290
  connection.send({ type: 'agent:activity', agentId, channelId, activity: 'online', detail: '', entries: [] });
@@ -206,36 +328,58 @@ export class AgentManager {
206
328
  return;
207
329
  }
208
330
 
209
- const text = `New message in ${message.channel_type === 'dm' ? `dm from` : `#${message.channel_name} from`} ${message.sender_name}: ${message.content}`;
331
+ const text = this._formatDeliveryText(message);
210
332
  console.log(`[AgentManager] Delivering seq=${seq} to agent ${agentId} channel=${channelId}`);
333
+ const agent = this.agents.get(key);
334
+ if (agent?.runtime === 'codex') {
335
+ if (!this._pendingMessages) this._pendingMessages = new Map();
336
+ const pending = this._pendingMessages.get(key) ?? [];
337
+ pending.push(msg);
338
+ this._pendingMessages.set(key, pending);
339
+ return;
340
+ }
211
341
  this._write(key, text);
212
342
  }
213
343
 
214
344
  _flushPending(key, connection) {
215
345
  if (!this._pendingMessages) return;
346
+ if (this.agents.get(key)?.runtime === 'codex') return;
216
347
  const pending = this._pendingMessages.get(key);
217
348
  if (!pending || pending.length === 0) return;
218
349
  this._pendingMessages.delete(key);
219
350
  for (const msg of pending) {
220
351
  const { agentId, channelId, seq, message } = msg;
221
- const text = `New message in ${message.channel_type === 'dm' ? `dm from` : `#${message.channel_name} from`} ${message.sender_name}: ${message.content}`;
352
+ const text = this._formatDeliveryText(message);
222
353
  console.log(`[AgentManager] Flushing queued seq=${seq} to agent ${agentId} channel=${channelId}`);
223
354
  this._write(key, text);
224
355
  }
225
356
  }
226
357
 
227
- // Write a user message to the running Claude process via stdin
358
+ // Write a user message to the running agent process via stdin
228
359
  _write(key, text) {
229
360
  const agent = this.agents.get(key);
230
361
  if (!agent?.proc) return;
231
- const line = JSON.stringify({
232
- type: 'user',
233
- message: { role: 'user', content: [{ type: 'text', text }] },
234
- }) + '\n';
362
+
363
+ let line;
364
+ if (agent.runtime === 'kimi') {
365
+ // Kimi uses JSON-RPC: prompt (idle) or steer (busy)
366
+ const mode = agent.kimiIdle ? 'idle' : 'busy';
367
+ agent.kimiIdle = false; // will be set back to true on TurnEnd
368
+ line = encodeKimiStdin(text, mode) + '\n';
369
+ } else if (agent.runtime === 'codex') {
370
+ return;
371
+ } else {
372
+ // Claude uses stream-json
373
+ line = JSON.stringify({
374
+ type: 'user',
375
+ message: { role: 'user', content: [{ type: 'text', text }] },
376
+ }) + '\n';
377
+ }
378
+
235
379
  try {
236
380
  agent.proc.stdin.write(line);
237
381
  } catch (err) {
238
- console.error(`[AgentManager] stdin write error for ${agentId}:`, err.message);
382
+ console.error(`[AgentManager] stdin write error for ${key}:`, err.message);
239
383
  }
240
384
  }
241
385
 
@@ -250,14 +394,29 @@ export class AgentManager {
250
394
  ? this._workspaceDir(agentId, agent.channelId)
251
395
  : this._workspaceDir(agentId, channelId);
252
396
 
253
- const globalDirs = [
254
- path.join(home, '.claude', 'skills'),
255
- path.join(home, '.claude', 'commands'),
256
- ];
257
- const workspaceDirs = [
258
- path.join(workspaceDir, '.claude', 'skills'),
259
- path.join(workspaceDir, '.claude', 'commands'),
260
- ];
397
+ const runtime = agent?.config?.runtime ?? 'claude';
398
+ const skillDirs = runtime === 'codex'
399
+ ? {
400
+ global: [
401
+ path.join(home, '.codex', 'skills'),
402
+ path.join(home, '.codex', 'skills', '.system'),
403
+ path.join(home, '.agents', 'skills'),
404
+ ],
405
+ workspace: [
406
+ path.join(workspaceDir, '.codex', 'skills'),
407
+ path.join(workspaceDir, '.agents', 'skills'),
408
+ ],
409
+ }
410
+ : {
411
+ global: [
412
+ path.join(home, '.claude', 'skills'),
413
+ path.join(home, '.claude', 'commands'),
414
+ ],
415
+ workspace: [
416
+ path.join(workspaceDir, '.claude', 'skills'),
417
+ path.join(workspaceDir, '.claude', 'commands'),
418
+ ],
419
+ };
261
420
 
262
421
  const dedup = (skills) => {
263
422
  const seen = new Set();
@@ -272,8 +431,8 @@ export class AgentManager {
272
431
  sourcePath: s.sourcePath?.startsWith(home) ? '~' + s.sourcePath.slice(home.length) : s.sourcePath,
273
432
  }));
274
433
 
275
- const global = shorten(dedup(globalDirs.flatMap(d => this._scanSkillsDir(d))));
276
- const workspace = shorten(dedup(workspaceDirs.flatMap(d => this._scanSkillsDir(d))));
434
+ const global = shorten(dedup(skillDirs.global.flatMap(d => this._scanSkillsDir(d))));
435
+ const workspace = shorten(dedup(skillDirs.workspace.flatMap(d => this._scanSkillsDir(d))));
277
436
 
278
437
  connection.send({ type: 'agent:skills:list_result', agentId, requestId, global, workspace });
279
438
  }
@@ -317,6 +476,63 @@ export class AgentManager {
317
476
  return info;
318
477
  }
319
478
 
479
+ _parseKimiLine(key, agentId, channelId, line, connection) {
480
+ const agent = this.agents.get(key);
481
+ if (!agent) return;
482
+ const events = parseKimiLine(line, agent.kimiState);
483
+ for (const evt of events) {
484
+ switch (evt.kind) {
485
+ case 'session_init':
486
+ if (agent.sessionId !== evt.sessionId) {
487
+ agent.sessionId = evt.sessionId;
488
+ connection.send({ type: 'agent:session', agentId, channelId, sessionId: evt.sessionId });
489
+ }
490
+ break;
491
+ case 'thinking':
492
+ connection.send({ type: 'agent:activity', agentId, channelId, activity: 'thinking', detail: '', entries: [] });
493
+ break;
494
+ case 'tool_call':
495
+ connection.send({ type: 'agent:activity', agentId, channelId, activity: 'working', detail: evt.name, entries: [] });
496
+ break;
497
+ case 'turn_end':
498
+ agent.kimiIdle = true;
499
+ connection.send({ type: 'agent:activity', agentId, channelId, activity: 'online', detail: '', entries: [] });
500
+ break;
501
+ case 'error':
502
+ console.error(`[AgentManager][kimi][${agentId}] Error: ${evt.message}`);
503
+ break;
504
+ }
505
+ }
506
+ }
507
+
508
+ _parseCodexLine(key, agentId, channelId, line, connection) {
509
+ const agent = this.agents.get(key);
510
+ if (!agent) return;
511
+ const events = parseCodexLine(line);
512
+ for (const evt of events) {
513
+ switch (evt.kind) {
514
+ case 'session_init':
515
+ if (agent.sessionId !== evt.sessionId) {
516
+ agent.sessionId = evt.sessionId;
517
+ connection.send({ type: 'agent:session', agentId, channelId, sessionId: evt.sessionId });
518
+ }
519
+ break;
520
+ case 'thinking':
521
+ connection.send({ type: 'agent:activity', agentId, channelId, activity: 'thinking', detail: '', entries: [] });
522
+ break;
523
+ case 'tool_call':
524
+ connection.send({ type: 'agent:activity', agentId, channelId, activity: 'working', detail: evt.name, entries: [] });
525
+ break;
526
+ case 'turn_end':
527
+ connection.send({ type: 'agent:activity', agentId, channelId, activity: 'online', detail: '', entries: [] });
528
+ break;
529
+ case 'error':
530
+ console.error(`[AgentManager][codex][${agentId}] Error: ${evt.message}`);
531
+ break;
532
+ }
533
+ }
534
+ }
535
+
320
536
  _parseLine(key, agentId, channelId, line, connection) {
321
537
  let event;
322
538
  try { event = JSON.parse(line); }
@@ -3,10 +3,16 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
5
 
6
- const SERVER_URL = process.env.SERVER_URL ?? 'http://localhost:8777';
7
- const MACHINE_API_KEY = process.env.MACHINE_API_KEY ?? '';
8
- const AGENT_ID = process.env.AGENT_ID ?? '';
9
- const CHANNEL_ID = process.env.CHANNEL_ID ?? ''; // injected per-channel at spawn time
6
+ const cliArgs = process.argv.slice(2);
7
+ function getArg(name) {
8
+ const idx = cliArgs.indexOf(name);
9
+ return idx !== -1 && cliArgs[idx + 1] ? cliArgs[idx + 1] : '';
10
+ }
11
+
12
+ const SERVER_URL = process.env.SERVER_URL || getArg('--server-url') || 'http://localhost:8777';
13
+ const MACHINE_API_KEY = process.env.MACHINE_API_KEY || getArg('--auth-token') || '';
14
+ const AGENT_ID = process.env.AGENT_ID || getArg('--agent-id') || '';
15
+ const CHANNEL_ID = process.env.CHANNEL_ID || getArg('--channel-id') || ''; // injected per-channel at spawn time
10
16
 
11
17
  // Current active channelId for memory isolation (defaults to spawn-time CHANNEL_ID)
12
18
  let currentChannelId = CHANNEL_ID;
@@ -57,24 +63,100 @@ server.tool('send_message', 'Send a message to a channel, DM, or thread', {
57
63
  });
58
64
 
59
65
  // ── read_history ──────────────────────────────────────────────────────────────
60
- server.tool('read_history', 'Read message history from a channel', {
61
- channel: z.string().describe('Target: #channel-name | dm:@agentName'),
62
- limit: z.number().optional().describe('Max messages to return (default 50)'),
66
+ server.tool('read_history', 'Read message history from a channel, DM, or thread. Supports pagination via before/after and context jumps via around.', {
67
+ channel: z.string().describe('Target: #channel-name | dm:@agentName | #channel-name:shortMsgId'),
68
+ limit: z.number().optional().describe('Max messages to return (default 50, max 100)'),
69
+ around: z.union([z.string(), z.number()]).optional().describe('Center the result window around a messageId or seq number'),
63
70
  before: z.number().optional().describe('Return messages before this seq'),
64
71
  after: z.number().optional().describe('Return messages after this seq'),
65
- }, async ({ channel, limit, before, after }) => {
66
- const params = new URLSearchParams({ channel, limit: String(limit ?? 50) });
72
+ }, async ({ channel, limit, around, before, after }) => {
73
+ const params = new URLSearchParams({ channel, limit: String(Math.min(limit ?? 50, 100)) });
74
+ if (around != null) params.set('around', String(around));
67
75
  if (before != null) params.set('before', String(before));
68
76
  if (after != null) params.set('after', String(after));
69
77
  const data = await api('GET', `/history?${params}`);
70
78
  const msgs = data.messages ?? [];
71
79
  if (msgs.length === 0) return { content: [{ type: 'text', text: 'No messages found.' }] };
72
80
 
73
- const text = msgs.map(m =>
74
- `[seq=${m.seq}] ${m.senderName}: ${m.content}`
75
- + (m.taskStatus ? ` [task #${m.taskNumber} ${m.taskStatus}]` : '')
76
- ).join('\n');
77
- return { content: [{ type: 'text', text }] };
81
+ const text = msgs.map(m => {
82
+ const senderType = m.senderType === 'agent' ? ' type=agent' : '';
83
+ const taskSuffix = m.taskStatus ? ` [task #${m.taskNumber} status=${m.taskStatus}${m.taskAssigneeId ? ` assignee=${m.taskAssigneeType}:${m.taskAssigneeId}` : ''}]` : '';
84
+ return `[seq=${m.seq} msg=${m.id} time=${m.createdAt}${senderType}] @${m.senderName}: ${m.content}${taskSuffix}`;
85
+ }).join('\n');
86
+
87
+ let footer = '';
88
+ if (data.has_more && msgs.length > 0) {
89
+ const minSeq = msgs[0].seq;
90
+ const maxSeq = msgs[msgs.length - 1].seq;
91
+ if (around) {
92
+ footer = `\n\n--- Use before=${minSeq} to load older or after=${maxSeq} to load newer. ---`;
93
+ } else if (after) {
94
+ footer = `\n\n--- ${msgs.length} messages shown. Use after=${maxSeq} for more. ---`;
95
+ } else {
96
+ footer = `\n\n--- ${msgs.length} messages shown. Use before=${minSeq} for older. ---`;
97
+ }
98
+ }
99
+
100
+ return { content: [{ type: 'text', text: `## History for ${channel} (${msgs.length} messages)\n\n${text}${footer}` }] };
101
+ });
102
+
103
+ // ── search_messages ──────────────────────────────────────────────────────────
104
+ server.tool('search_messages', 'Search messages visible to you. Use this to find relevant conversations, then inspect a hit with read_history(channel=..., around=messageId).', {
105
+ query: z.string().describe('Search query'),
106
+ channel: z.string().optional().describe('Optional target to scope the search, e.g. "#general", "dm:@richard"'),
107
+ limit: z.number().optional().describe('Max results (default 10, max 20)'),
108
+ }, async ({ query, channel, limit }) => {
109
+ const trimmed = query.trim();
110
+ if (!trimmed) return { content: [{ type: 'text', text: 'Search query cannot be empty.' }] };
111
+ const params = new URLSearchParams({ q: trimmed, limit: String(Math.min(limit ?? 10, 20)) });
112
+ if (channel) params.set('channel', channel);
113
+ try {
114
+ const data = await api('GET', `/search?${params}`);
115
+ if (!data.results || data.results.length === 0)
116
+ return { content: [{ type: 'text', text: 'No search results.' }] };
117
+ const formatted = data.results.map((r, i) => [
118
+ `[${i + 1}] msg=${r.id} seq=${r.seq} time=${r.createdAt}`,
119
+ `channel: #${r.channelName}`,
120
+ `sender: @${r.senderName}${r.senderType === 'agent' ? ' (agent)' : ''}`,
121
+ `content: ${r.snippet}`,
122
+ `next: read_history(channel="#${r.channelName}", around="${r.id}", limit=20)`,
123
+ ].join('\n')).join('\n\n');
124
+ return { content: [{ type: 'text', text: `## Search Results for "${trimmed}" (${data.results.length} results)\n\n${formatted}` }] };
125
+ } catch (err) {
126
+ return { isError: true, content: [{ type: 'text', text: `Error: ${err.message}` }] };
127
+ }
128
+ });
129
+
130
+ // ── view_file ────────────────────────────────────────────────────────────────
131
+ server.tool('view_file', 'Download an attached image by its attachment ID and save it locally so you can view it. Use this when messages contain image attachments.', {
132
+ attachment_id: z.string().describe('The attachment UUID'),
133
+ }, async ({ attachment_id }) => {
134
+ try {
135
+ const fs = await import('fs');
136
+ const pathMod = await import('path');
137
+ const os = await import('os');
138
+ const cacheDir = pathMod.join(os.homedir(), '.lightcone', 'attachments');
139
+ fs.mkdirSync(cacheDir, { recursive: true });
140
+ // Check cache
141
+ const existing = fs.readdirSync(cacheDir).find(f => f.startsWith(attachment_id));
142
+ if (existing) {
143
+ return { content: [{ type: 'text', text: `File already cached at: ${pathMod.join(cacheDir, existing)}\n\nUse your Read tool to view this image.` }] };
144
+ }
145
+ const res = await fetch(`${SERVER_URL}/api/attachments/${attachment_id}`, {
146
+ headers: { 'Authorization': `Bearer ${MACHINE_API_KEY}` },
147
+ redirect: 'follow',
148
+ });
149
+ if (!res.ok) return { isError: true, content: [{ type: 'text', text: `Error: Failed to download attachment (${res.status})` }] };
150
+ const contentType = res.headers.get('content-type') || 'application/octet-stream';
151
+ const extMap = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' };
152
+ const ext = extMap[contentType] || '.bin';
153
+ const filePath = pathMod.join(cacheDir, `${attachment_id}${ext}`);
154
+ const buffer = Buffer.from(await res.arrayBuffer());
155
+ fs.writeFileSync(filePath, buffer);
156
+ return { content: [{ type: 'text', text: `Downloaded to: ${filePath}\n\nUse your Read tool to view this image.` }] };
157
+ } catch (err) {
158
+ return { isError: true, content: [{ type: 'text', text: `Error: ${err.message}` }] };
159
+ }
78
160
  });
79
161
 
80
162
  // ── list_server ───────────────────────────────────────────────────────────────
package/src/connection.js CHANGED
@@ -1,8 +1,22 @@
1
1
  import WebSocket from 'ws';
2
2
  import os from 'os';
3
+ import { execSync } from 'child_process';
3
4
 
4
5
  const RECONNECT_INITIAL = 1000;
5
6
  const RECONNECT_MAX = 30000;
7
+ const KNOWN_RUNTIMES = ['claude', 'codex', 'kimi'];
8
+
9
+ function detectRuntimes() {
10
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
11
+ const runtimes = [];
12
+ for (const runtime of KNOWN_RUNTIMES) {
13
+ try {
14
+ execSync(`${cmd} ${runtime}`, { stdio: 'pipe' });
15
+ runtimes.push(runtime);
16
+ } catch {}
17
+ }
18
+ return runtimes;
19
+ }
6
20
 
7
21
  export class DaemonConnection {
8
22
  constructor({ serverUrl, machineApiKey, onMessage }) {
@@ -58,7 +72,7 @@ export class DaemonConnection {
58
72
  type: 'ready',
59
73
  hostname: os.hostname(),
60
74
  os: `${os.platform()} ${os.arch()}`,
61
- runtimes: ['claude'],
75
+ runtimes: detectRuntimes(),
62
76
  daemonVersion: '0.1.0',
63
77
  });
64
78
  }