@openagents-org/agent-launcher 0.2.116 → 0.2.118

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": "@openagents-org/agent-launcher",
3
- "version": "0.2.116",
3
+ "version": "0.2.118",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -391,6 +391,53 @@ class BaseAdapter {
391
391
  }
392
392
  }
393
393
 
394
+ async cleanupTodos(channel) {
395
+ try {
396
+ const result = await this.client.getTodos(this.workspaceId, channel, this.token, {
397
+ all: false,
398
+ });
399
+ const todos = (result && result.todos) || [];
400
+ const hasActive = todos.some((t) => t.status === 'pending' || t.status === 'in_progress');
401
+ if (!hasActive) return;
402
+ const updated = todos.map((t) => ({
403
+ content: t.content,
404
+ status: (t.status === 'pending' || t.status === 'in_progress') ? 'cancelled' : t.status,
405
+ assignee: t.assignee,
406
+ }));
407
+ await this.client.putTodos(this.workspaceId, channel, this.token, updated, {
408
+ source: `openagents:${this.agentName}`,
409
+ });
410
+ } catch {
411
+ // Best-effort cleanup
412
+ }
413
+ }
414
+
415
+ async sendTodos(channel, todos) {
416
+ try {
417
+ await this.client.putTodos(this.workspaceId, channel, this.token, todos, {
418
+ source: `openagents:${this.agentName}`,
419
+ });
420
+ } catch (e) {
421
+ if (e instanceof SessionRevokedError) { this._onSessionRevoked(); return; }
422
+ // Fallback to event-based approach for older backends
423
+ const lines = todos.map((t) => {
424
+ const icon = t.status === 'completed' ? '✅' : t.status === 'in_progress' ? '🔄' : '⬜';
425
+ return `${icon} ${t.content}`;
426
+ });
427
+ try {
428
+ await this.client.sendMessage(this.workspaceId, channel, this.token, lines.join('\n'), {
429
+ senderType: 'agent',
430
+ senderName: this.agentName,
431
+ messageType: 'todos',
432
+ metadata: { agent_mode: this._mode, todos },
433
+ sessionId: this._sessionId,
434
+ });
435
+ } catch (e2) {
436
+ if (e2 instanceof SessionRevokedError) this._onSessionRevoked();
437
+ }
438
+ }
439
+ }
440
+
394
441
  async sendError(channel, error) {
395
442
  try {
396
443
  await this.client.sendMessage(this.workspaceId, channel, this.token, error, {
@@ -18,7 +18,7 @@ const { execSync, spawn } = require('child_process');
18
18
 
19
19
  const BaseAdapter = require('./base');
20
20
  const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
21
- const { buildClaudeSystemPrompt } = require('./workspace-prompt');
21
+ const { buildClaudeSystemPrompt, buildClaudeSkillMd } = require('./workspace-prompt');
22
22
 
23
23
  const IS_WINDOWS = process.platform === 'win32';
24
24
 
@@ -31,6 +31,8 @@ class ClaudeAdapter extends BaseAdapter {
31
31
  constructor(opts) {
32
32
  super(opts);
33
33
  this.disabledModules = opts.disabledModules || new Set();
34
+ /** @type {'mcp' | 'skills'} Tool integration mode */
35
+ this.toolMode = opts.toolMode || 'mcp';
34
36
  this._channelSessions = {}; // channel → Claude CLI session_id
35
37
  this._channelProcesses = {}; // channel → child process
36
38
  this._stoppingChannels = new Set();
@@ -285,15 +287,81 @@ class ClaudeAdapter extends BaseAdapter {
285
287
  throw new Error('claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash');
286
288
  }
287
289
 
288
- const systemPrompt = '\n' + buildClaudeSystemPrompt({
290
+ let systemPrompt = '\n' + buildClaudeSystemPrompt({
289
291
  agentName: this.agentName,
290
292
  workspaceId: this.workspaceId,
291
293
  channelName,
292
294
  mode: this._mode,
293
295
  });
294
296
 
297
+ // In skills mode, replace MCP tool references with curl-based instructions
298
+ if (this.toolMode === 'skills') {
299
+ systemPrompt = systemPrompt
300
+ .replace(
301
+ 'Use workspace_get_history to read previous messages.\n' +
302
+ 'Use workspace_get_agents to see other agents.\n',
303
+ 'Use the openagents-workspace skill (Bash + curl) for workspace operations:\n' +
304
+ 'reading message history, discovering agents, sharing files, and browsing.\n' +
305
+ 'Refer to the skill instructions for the exact curl commands.\n'
306
+ );
307
+ }
308
+
295
309
  const cmd = [claudeBin, '-p', prompt, '--output-format', 'stream-json', '--verbose'];
296
310
 
311
+ cmd.push('--append-system-prompt', systemPrompt);
312
+ cmd.push('--disallowedTools', 'AskUserQuestion');
313
+
314
+ // Resume existing conversation (skipped on retry after stale session)
315
+ const sessionId = this._channelSessions[channelName];
316
+ if (sessionId && !skipResume) {
317
+ cmd.push('--resume', sessionId);
318
+ }
319
+
320
+ // ── Skills mode: write SKILL.md, no MCP server ──
321
+ if (this.toolMode === 'skills') {
322
+ return this._buildSkillsCmd(cmd, channelName);
323
+ }
324
+
325
+ // ── MCP mode (default): spawn MCP server ──
326
+ return this._buildMcpCmd(cmd, channelName);
327
+ }
328
+
329
+ /**
330
+ * Skills mode: write a SKILL.md file and allow Bash + curl for workspace ops.
331
+ */
332
+ _buildSkillsCmd(cmd, channelName) {
333
+ if (this._mode === 'plan') {
334
+ cmd.push('--permission-mode', 'plan');
335
+ cmd.push('--allowedTools', 'Read', 'Glob', 'Grep', 'Bash');
336
+ } else {
337
+ cmd.push('--dangerously-skip-permissions');
338
+ cmd.push('--allowedTools', 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep');
339
+ }
340
+
341
+ // Write SKILL.md to .claude/skills/ in the working directory
342
+ const workDir = this.workingDir || process.cwd();
343
+ const skillDir = path.join(workDir, '.claude', 'skills');
344
+ fs.mkdirSync(skillDir, { recursive: true });
345
+ const skillFile = path.join(skillDir, 'openagents-workspace.md');
346
+
347
+ const skillContent = buildClaudeSkillMd({
348
+ endpoint: this.endpoint,
349
+ workspaceId: this.workspaceId,
350
+ token: this.token,
351
+ agentName: this.agentName,
352
+ channelName,
353
+ disabledModules: this.disabledModules,
354
+ });
355
+ fs.writeFileSync(skillFile, skillContent, 'utf-8');
356
+ this._log(`Wrote workspace skill to ${skillFile}`);
357
+
358
+ return { cmd, skillFile };
359
+ }
360
+
361
+ /**
362
+ * MCP mode (default): spawn MCP server subprocess for workspace tools.
363
+ */
364
+ _buildMcpCmd(cmd, channelName) {
297
365
  // Mode-dependent permission and tool flags
298
366
  const pfx = 'mcp__openagents-workspace__';
299
367
  const mcpTools = [
@@ -326,23 +394,16 @@ class ClaudeAdapter extends BaseAdapter {
326
394
  mcpWriteTools.push(`${pfx}tunnel_expose`, `${pfx}tunnel_close`);
327
395
  }
328
396
 
329
- let allowed;
397
+ // Todos & Timers (always enabled)
398
+ mcpTools.push(`${pfx}workspace_get_todos`, `${pfx}workspace_list_timers`);
399
+ mcpWriteTools.push(`${pfx}workspace_put_todos`, `${pfx}workspace_create_timer`, `${pfx}workspace_cancel_timer`);
400
+
330
401
  if (this._mode === 'plan') {
331
402
  cmd.push('--permission-mode', 'plan');
332
- allowed = [...mcpTools, 'Read', 'Glob', 'Grep'];
403
+ cmd.push('--allowedTools', ...mcpTools, 'Read', 'Glob', 'Grep');
333
404
  } else {
334
405
  cmd.push('--dangerously-skip-permissions');
335
- allowed = [...mcpTools, ...mcpWriteTools, 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'];
336
- }
337
-
338
- cmd.push('--append-system-prompt', systemPrompt);
339
- cmd.push('--allowedTools', ...allowed);
340
- cmd.push('--disallowedTools', 'AskUserQuestion');
341
-
342
- // Resume existing conversation (skipped on retry after stale session)
343
- const sessionId = this._channelSessions[channelName];
344
- if (sessionId && !skipResume) {
345
- cmd.push('--resume', sessionId);
406
+ cmd.push('--allowedTools', ...mcpTools, ...mcpWriteTools, 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep');
346
407
  }
347
408
 
348
409
  // MCP config for workspace tools
@@ -356,18 +417,13 @@ class ClaudeAdapter extends BaseAdapter {
356
417
  if (this.disabledModules.has('files')) mcpArgs.push('--disable-files');
357
418
  if (this.disabledModules.has('browser')) mcpArgs.push('--disable-browser');
358
419
 
359
- // Resolve the MCP server entry point. Prefer the sibling bin inside this
360
- // very package — it's guaranteed to exist whenever claude.js is executing,
361
- // so it never falls through to a broken PATH lookup. If Claude Code can't
362
- // spawn the MCP server, it silently hides every workspace tool and the
363
- // agent reports "workspace_read_file isn't in my tool set".
420
+ // Resolve the MCP server entry point
364
421
  let mcpCommand = this._findNodeBin();
365
422
  let mcpFinalArgs = mcpArgs;
366
423
  const siblingBin = path.resolve(__dirname, '..', '..', 'bin', 'agent-connector.js');
367
424
  if (fs.existsSync(siblingBin)) {
368
425
  mcpFinalArgs = [siblingBin, ...mcpArgs];
369
426
  } else {
370
- // Fallback: search installed locations (older layouts, global installs)
371
427
  let oaBin = null;
372
428
  const home3 = os.homedir();
373
429
  const oaExt = IS_WINDOWS ? '.cmd' : '';
@@ -475,16 +531,20 @@ class ClaudeAdapter extends BaseAdapter {
475
531
  let mcpConfigFile = null;
476
532
  let cmd;
477
533
 
478
- // Clean env: strip every CLAUDE_* / AI_AGENT variable inherited from a
479
- // parent Claude Code (or Claude Agent SDK) process. If we don't, the
480
- // spawned `claude` thinks it's running under an SDK harness and picks
481
- // an org-scoped auth path that returns 403 "Account is no longer a
482
- // member of the organization" even when the user is logged in fine via
483
- // `claude login`. We let the child rediscover auth from
484
- // ~/.claude/.credentials.json (or ANTHROPIC_API_KEY if set).
534
+ // Clean env: strip CLAUDE_* / AI_AGENT variables that make the spawned
535
+ // `claude` think it's running under an SDK harness (org-scoped auth
536
+ // path 403). But preserve config vars the child needs for cloud
537
+ // provider auth (Vertex, Bedrock) and model selection.
538
+ const CLAUDE_ENV_KEEP = new Set([
539
+ 'CLAUDE_CODE_USE_VERTEX',
540
+ 'CLAUDE_CODE_USE_BEDROCK',
541
+ 'CLAUDE_MODEL',
542
+ 'CLAUDE_API_KEY',
543
+ 'CLAUDE_CODE_MAX_TURNS',
544
+ ]);
485
545
  const cleanEnv = { ...(this.agentEnv || process.env) };
486
546
  for (const k of Object.keys(cleanEnv)) {
487
- if (k.startsWith('CLAUDE_') || k === 'CLAUDECODE' || k === 'AI_AGENT') {
547
+ if ((k.startsWith('CLAUDE_') && !CLAUDE_ENV_KEEP.has(k)) || k === 'CLAUDECODE' || k === 'AI_AGENT') {
488
548
  delete cleanEnv[k];
489
549
  }
490
550
  }
@@ -538,6 +598,7 @@ class ClaudeAdapter extends BaseAdapter {
538
598
  const lastResponseText = [];
539
599
  let hasToolUseSinceLastText = false;
540
600
  let postedThinking = false;
601
+ let everPostedAnything = false;
541
602
  let stderrBuf = '';
542
603
  let lineBuffer = '';
543
604
  let _pendingLines = Promise.resolve(); // chain of in-flight processLine calls
@@ -597,6 +658,7 @@ class ClaudeAdapter extends BaseAdapter {
597
658
  }
598
659
  lastResponseText.push(block.text.trim());
599
660
  postedThinking = true;
661
+ everPostedAnything = true;
600
662
  // Stream text in real-time as "thinking" (same as Python adapter)
601
663
  try { await this.sendThinking(msgChannel, block.text.trim()); } catch {}
602
664
  } else if (block.type === 'tool_use') {
@@ -604,6 +666,7 @@ class ClaudeAdapter extends BaseAdapter {
604
666
  postedThinking = false;
605
667
  lastResponseText.length = 0;
606
668
  const toolName = block.name || '';
669
+
607
670
  // Format tool input as readable text
608
671
  let inputPreview = '';
609
672
  if (block.input && typeof block.input === 'object') {
@@ -620,6 +683,7 @@ class ClaudeAdapter extends BaseAdapter {
620
683
  inputPreview = String(block.input || '').slice(0, 150);
621
684
  }
622
685
  await this.sendStatus(msgChannel, `${toolName} › ${inputPreview}`);
686
+ everPostedAnything = true;
623
687
  }
624
688
  }
625
689
  } else if (eventType === 'result') {
@@ -655,6 +719,10 @@ class ClaudeAdapter extends BaseAdapter {
655
719
  }
656
720
 
657
721
  delete this._channelProcesses[msgChannel];
722
+
723
+ // Clean up stale todos: mark pending/in_progress as cancelled
724
+ try { await this.cleanupTodos(msgChannel); } catch {}
725
+
658
726
  const stoppedByUser = this._stoppingChannels.has(msgChannel);
659
727
  if (stoppedByUser) {
660
728
  this._stoppingChannels.delete(msgChannel);
@@ -662,8 +730,8 @@ class ClaudeAdapter extends BaseAdapter {
662
730
  return;
663
731
  }
664
732
 
733
+ this._log(`CLI exited: code=${code}, lastResponseText=${lastResponseText.length} items, everPosted=${everPostedAnything}, hasSession=${!!this._channelSessions[msgChannel]}`);
665
734
  if (code !== 0) {
666
- this._log(`CLI exited with code ${code}`);
667
735
  if (stderrBuf.trim()) {
668
736
  this._log(`stderr: ${stderrBuf.trim().slice(0, 500)}`);
669
737
  }
@@ -675,14 +743,16 @@ class ClaudeAdapter extends BaseAdapter {
675
743
  try { await this.sendResponse(msgChannel, fullResponse); } catch {}
676
744
  }
677
745
  resolve(false); // done, no retry
678
- } else if (code !== 0 && this._channelSessions[msgChannel]) {
679
- // No output + error exit + had a resume session → stale session, signal retry
746
+ } else if (this._channelSessions[msgChannel] && !everPostedAnything) {
747
+ // No output + had a resume session → likely stale session, retry fresh.
748
+ // Covers both error exits (code !== 0) and silent success (code === 0)
749
+ // where Claude produced no text output on a resumed conversation.
680
750
  this._log(`Stale session detected for ${msgChannel}, clearing and retrying without resume`);
681
751
  delete this._channelSessions[msgChannel];
682
752
  this._saveSessions();
683
753
  resolve(true); // retry=true
684
754
  } else {
685
- if (!postedThinking) {
755
+ if (!everPostedAnything) {
686
756
  try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
687
757
  }
688
758
  resolve(false);
@@ -205,9 +205,73 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
205
205
  sections.push(s);
206
206
  }
207
207
 
208
+ // Message history
209
+ sections.push(
210
+ '\n### Message History\n\n' +
211
+ '**Get recent messages in the current channel:**\n' +
212
+ `\`curl -s -H "${h}" "${baseUrl}/v1/events?network=${workspaceId}&channel=${channelName}&type=workspace.message&limit=20"\`\n\n` +
213
+ '**Get messages from a specific channel:**\n' +
214
+ `\`curl -s -H "${h}" "${baseUrl}/v1/events?network=${workspaceId}&channel=CHANNEL_NAME&type=workspace.message&limit=20"\`\n`
215
+ );
216
+
217
+ // Post status update
218
+ if (!isPlan) {
219
+ sections.push(
220
+ '\n### Post Status Update\n\n' +
221
+ 'Post a status/thinking message (visible in the workspace UI as an intermediate step):\n' +
222
+ `\`curl -s -X POST -H "${h}" -H "Content-Type: application/json" ` +
223
+ `${baseUrl}/v1/events -d '{"type":"workspace.message.posted",` +
224
+ `"source":"openagents:${agentName}","target":"channel/${channelName}",` +
225
+ `"payload":{"content":"YOUR_STATUS","message_type":"status"}}'\`\n`
226
+ );
227
+ }
228
+
229
+ // To-Dos (planning)
230
+ if (!isPlan) {
231
+ sections.push(
232
+ '\n### To-Do List (Planning)\n\n' +
233
+ 'Create or update your to-do list to track progress. The entire list ' +
234
+ 'is replaced each time (send the full list with current statuses).\n\n' +
235
+ '**Status values:** `pending`, `in_progress`, `completed`\n\n' +
236
+ '**Update your to-do list:**\n' +
237
+ `\`curl -s -X PUT -H "${h}" -H "Content-Type: application/json" ` +
238
+ `${baseUrl}/v1/todos -d '{"todos":[` +
239
+ `{"content":"First task","status":"in_progress"},` +
240
+ `{"content":"Second task","status":"pending"}` +
241
+ `],"network":"${workspaceId}","channel":"${channelName}",` +
242
+ `"source":"openagents:${agentName}"}'\`\n\n` +
243
+ '**Get your to-do list:**\n' +
244
+ `\`curl -s -H "${h}" "${baseUrl}/v1/todos?network=${workspaceId}&channel=${channelName}"\`\n\n` +
245
+ 'Use to-dos to plan multi-step work. Update the list as you complete each step.\n' +
246
+ 'You can assign items to other agents: `"assignee": "other-agent-name"`\n'
247
+ );
248
+ }
249
+
250
+ // Timers
251
+ if (!isPlan) {
252
+ sections.push(
253
+ '\n### Timers\n\n' +
254
+ 'Set a timer that will send you a message after a delay, waking you up ' +
255
+ 'to continue work. Use this instead of `sleep` — timers let you release ' +
256
+ 'the session and get called back later.\n\n' +
257
+ 'Use cases: check back on a deploy, retry after a rate limit, remind ' +
258
+ 'yourself to follow up.\n\n' +
259
+ '**Create a timer:**\n' +
260
+ `\`curl -s -X POST -H "${h}" -H "Content-Type: application/json" ` +
261
+ `${baseUrl}/v1/timers -d '{"delay":300,"message":"Check the build",` +
262
+ `"network":"${workspaceId}","channel":"${channelName}",` +
263
+ `"source":"openagents:${agentName}"}'\`\n\n` +
264
+ '**List active timers:**\n' +
265
+ `\`curl -s -H "${h}" "${baseUrl}/v1/timers?network=${workspaceId}&channel=${channelName}"\`\n\n` +
266
+ '**Cancel a timer:**\n' +
267
+ `\`curl -s -X DELETE -H "${h}" ${baseUrl}/v1/timers/TIMER_ID\`\n`
268
+ );
269
+ }
270
+
208
271
  // Discovery
209
272
  sections.push(
210
- '\n### Discover Agents\n' +
273
+ '\n### Discover Agents\n\n' +
274
+ '**List all agents in the workspace (with status and role):**\n' +
211
275
  `\`curl -s -H "${h}" ${baseUrl}/v1/discover?network=${workspaceId}\`\n`
212
276
  );
213
277
 
@@ -321,6 +385,37 @@ function buildOpenCodeSkillMd({ endpoint, workspaceId, token, agentName, channel
321
385
  return frontmatter + identity + api;
322
386
  }
323
387
 
388
+ /**
389
+ * Build a SKILL.md file for Claude Code's skill auto-discovery.
390
+ *
391
+ * When tool_mode is 'skills', the Claude adapter writes this file instead
392
+ * of spawning an MCP server. Claude Code discovers the skill via its
393
+ * .claude/skills/ directory and uses Bash + curl to call workspace APIs.
394
+ */
395
+ function buildClaudeSkillMd({ endpoint, workspaceId, token, agentName, channelName, disabledModules }) {
396
+ const api = buildApiSkillsPrompt({
397
+ endpoint, workspaceId, token, agentName,
398
+ channelName: channelName || 'general',
399
+ disabledModules,
400
+ mode: 'execute',
401
+ });
402
+
403
+ const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, 'execute');
404
+ const collab = buildCollaborationPrompt();
405
+
406
+ const frontmatter =
407
+ '---\n' +
408
+ 'name: openagents-workspace\n' +
409
+ 'description: |\n' +
410
+ ' OpenAgents Workspace collaboration tools — shared files, browser,\n' +
411
+ ' and multi-agent coordination. Use when: sharing files or reports,\n' +
412
+ ' browsing websites, reading shared files, checking workspace agents,\n' +
413
+ ' or collaborating with other agents via @mentions.\n' +
414
+ '---\n\n';
415
+
416
+ return frontmatter + identity + '\n' + collab + '\n' + api;
417
+ }
418
+
324
419
  module.exports = {
325
420
  buildWorkspaceIdentity,
326
421
  buildCollaborationPrompt,
@@ -331,4 +426,5 @@ module.exports = {
331
426
  buildOpenclawSkillMd,
332
427
  buildOpenCodeSystemPrompt,
333
428
  buildOpenCodeSkillMd,
429
+ buildClaudeSkillMd,
334
430
  };
package/src/cli.js CHANGED
@@ -440,6 +440,49 @@ async function cmdEnv(connector, flags, positional) {
440
440
  }
441
441
  }
442
442
 
443
+ async function cmdToolMode(connector, _flags, positional) {
444
+ const agentName = positional[0];
445
+ const mode = positional[1];
446
+
447
+ if (!agentName) {
448
+ // Show tool mode for all agents
449
+ const agents = connector.config.getAgents();
450
+ if (agents.length === 0) {
451
+ print('No agents configured');
452
+ return;
453
+ }
454
+ for (const a of agents) {
455
+ print(` ${a.name}: ${a.tool_mode || 'mcp'}`);
456
+ }
457
+ print('\nUsage: agn tool-mode <agent> <mcp|skills>');
458
+ return;
459
+ }
460
+
461
+ if (!mode) {
462
+ // Show tool mode for specific agent
463
+ const agent = connector.config.getAgent(agentName);
464
+ if (!agent) { print(`Agent '${agentName}' not found`); process.exitCode = 1; return; }
465
+ print(`${agentName}: ${agent.tool_mode || 'mcp'}`);
466
+ print('\nUsage: agn tool-mode <agent> <mcp|skills>');
467
+ return;
468
+ }
469
+
470
+ if (mode !== 'mcp' && mode !== 'skills') {
471
+ print(`Invalid mode: ${mode}. Must be 'mcp' or 'skills'.`);
472
+ process.exitCode = 1;
473
+ return;
474
+ }
475
+
476
+ connector.config.updateAgent(agentName, { tool_mode: mode });
477
+ try { connector.sendDaemonCommand('reload'); } catch {}
478
+ print(`Set tool mode for ${agentName} to '${mode}'`);
479
+ if (mode === 'skills') {
480
+ print('Agent will use SKILL.md (Bash + curl) instead of MCP server for workspace tools.');
481
+ } else {
482
+ print('Agent will use MCP server for workspace tools (default).');
483
+ }
484
+ }
485
+
443
486
  async function cmdTestLLM(connector, _flags, positional) {
444
487
  const type = positional[0];
445
488
  if (!type) { print('Usage: agn test-llm <type>'); return; }
@@ -504,6 +547,7 @@ Commands:
504
547
  connect <agent> <token> Connect agent to workspace
505
548
  disconnect <agent> Disconnect agent from workspace
506
549
  env <type> [--set K=V] View/set env vars for agent type
550
+ tool-mode [agent] [mode] View/set tool mode (mcp or skills)
507
551
  autostart [--disable] Enable/disable auto-start on login
508
552
  test-llm <type> Test LLM connection
509
553
  logs [agent] [--lines N] View daemon logs
@@ -589,6 +633,7 @@ async function main() {
589
633
  autostart: () => cmdAutostart(connector, flags),
590
634
  workspace: () => cmdWorkspace(connector, flags, positional),
591
635
  env: () => cmdEnv(connector, flags, positional),
636
+ 'tool-mode': () => cmdToolMode(connector, flags, positional),
592
637
  'test-llm': () => cmdTestLLM(connector, flags, positional),
593
638
  update: () => cmdUpdate(),
594
639
  'mcp-server': () => {
package/src/daemon.js CHANGED
@@ -433,6 +433,7 @@ class Daemon {
433
433
  disabledModules: new Set(),
434
434
  agentEnv: this._buildAgentEnv(agentCfg),
435
435
  workingDir: agentCfg.path || undefined,
436
+ toolMode: agentCfg.tool_mode || 'mcp',
436
437
  });
437
438
  } catch (e) {
438
439
  this._log(`${name} failed to create ${agentType} adapter: ${e.message}`);
package/src/mcp-server.js CHANGED
@@ -235,6 +235,72 @@ function buildToolDefs(disabledModules) {
235
235
  );
236
236
  }
237
237
 
238
+ // -- Todos & Timers (always enabled) --
239
+ tools.push(
240
+ {
241
+ name: 'workspace_put_todos',
242
+ description: 'Update your to-do list. Replaces the entire list each time (send full list with current statuses). Channel is auto-resolved.',
243
+ inputSchema: {
244
+ type: 'object',
245
+ properties: {
246
+ todos: {
247
+ type: 'array',
248
+ items: {
249
+ type: 'object',
250
+ properties: {
251
+ content: { type: 'string', description: 'Task description' },
252
+ status: { type: 'string', enum: ['pending', 'in_progress', 'completed'], description: 'Task status' },
253
+ assignee: { type: 'string', description: 'Agent name to assign to (defaults to self)' },
254
+ },
255
+ required: ['content', 'status'],
256
+ },
257
+ description: 'Full to-do list with current statuses',
258
+ },
259
+ },
260
+ required: ['todos'],
261
+ },
262
+ },
263
+ {
264
+ name: 'workspace_get_todos',
265
+ description: 'Get to-do items for the current channel. Use all=true to see all agents\' todos.',
266
+ inputSchema: {
267
+ type: 'object',
268
+ properties: {
269
+ agent: { type: 'string', description: 'Filter by agent name' },
270
+ all: { type: 'boolean', description: 'Get all agents\' todos (default: own only)' },
271
+ },
272
+ },
273
+ },
274
+ {
275
+ name: 'workspace_create_timer',
276
+ description: 'Set a timer that posts a message to the channel after the specified delay.',
277
+ inputSchema: {
278
+ type: 'object',
279
+ properties: {
280
+ delay: { type: 'integer', description: 'Seconds until the timer fires (1-86400)' },
281
+ message: { type: 'string', description: 'Message to post when the timer fires' },
282
+ },
283
+ required: ['delay', 'message'],
284
+ },
285
+ },
286
+ {
287
+ name: 'workspace_list_timers',
288
+ description: 'List active timers in the current channel.',
289
+ inputSchema: { type: 'object', properties: {} },
290
+ },
291
+ {
292
+ name: 'workspace_cancel_timer',
293
+ description: 'Cancel an active timer by its ID.',
294
+ inputSchema: {
295
+ type: 'object',
296
+ properties: {
297
+ timer_id: { type: 'string', description: 'Timer ID to cancel' },
298
+ },
299
+ required: ['timer_id'],
300
+ },
301
+ },
302
+ );
303
+
238
304
  return tools;
239
305
  }
240
306
 
@@ -596,6 +662,56 @@ class McpServer {
596
662
  return text(lines.join('\n'));
597
663
  }
598
664
 
665
+ // ── Todos & Timers ──
666
+
667
+ case 'workspace_put_todos': {
668
+ await this.ws.putTodos(this.workspaceId, this.channelName, this.token, args.todos, {
669
+ source: `openagents:${this.agentName}`,
670
+ });
671
+ const summary = (args.todos || []).map((t) => {
672
+ const icon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[~]' : '[ ]';
673
+ return `${icon} ${t.content}${t.assignee ? ` → ${t.assignee}` : ''}`;
674
+ }).join('\n');
675
+ return text(`Todos updated:\n${summary}`);
676
+ }
677
+
678
+ case 'workspace_get_todos': {
679
+ const data = await this.ws.getTodos(this.workspaceId, this.channelName, this.token, {
680
+ agent: args.agent, all: args.all,
681
+ });
682
+ const todos = (data && data.todos) || [];
683
+ if (!todos.length) return text('No todos.');
684
+ const lines = todos.map((t) => {
685
+ const icon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[~]' : '[ ]';
686
+ return `${icon} ${t.content} (${t.assignee || 'unassigned'})`;
687
+ });
688
+ return text(lines.join('\n'));
689
+ }
690
+
691
+ case 'workspace_create_timer': {
692
+ const result = await this.ws.createTimer(
693
+ this.workspaceId, this.channelName, this.token,
694
+ args.delay, args.message,
695
+ { source: `openagents:${this.agentName}` },
696
+ );
697
+ return text(`Timer set: "${args.message}" fires in ${args.delay}s (id: ${result.id})`);
698
+ }
699
+
700
+ case 'workspace_list_timers': {
701
+ const data = await this.ws.listTimers(this.workspaceId, this.channelName, this.token);
702
+ const timers = (data && data.timers) || [];
703
+ if (!timers.length) return text('No active timers.');
704
+ const lines = timers.map((t) =>
705
+ `- ${t.id}: "${t.message}" fires at ${t.fires_at} (by ${t.created_by})`
706
+ );
707
+ return text(lines.join('\n'));
708
+ }
709
+
710
+ case 'workspace_cancel_timer': {
711
+ await this.ws.cancelTimer(this.workspaceId, this.token, args.timer_id);
712
+ return text(`Timer cancelled: ${args.timer_id}`);
713
+ }
714
+
599
715
  default:
600
716
  throw new Error(`Unknown tool: ${name}`);
601
717
  }
@@ -293,6 +293,11 @@ class WorkspaceClient {
293
293
  // Legacy server (no target_agents): broadcast for compat
294
294
  messages.push(this._eventToMessage(e));
295
295
  }
296
+ } else if (source.startsWith('system:')) {
297
+ // System messages (timers, notifications): pick up if targeted
298
+ if (hasTargetList && targetAgents.includes(agentName)) {
299
+ messages.push(this._eventToMessage(e));
300
+ }
296
301
  } else if (source.startsWith('openagents:')) {
297
302
  // Agent messages: only pick up if explicitly listed
298
303
  if (hasTargetList && targetAgents.includes(agentName)) {
@@ -524,6 +529,53 @@ class WorkspaceClient {
524
529
  return data.data || data;
525
530
  }
526
531
 
532
+ // ── Todos & Timers ──
533
+
534
+ async putTodos(workspaceId, channelName, token, todos, { source } = {}) {
535
+ const body = {
536
+ todos,
537
+ network: workspaceId,
538
+ channel: channelName,
539
+ source: source || 'openagents:unknown',
540
+ };
541
+ const data = await this._put('/v1/todos', body, this._wsHeaders(token));
542
+ return data.data || data;
543
+ }
544
+
545
+ async getTodos(workspaceId, channelName, token, { agent, all } = {}) {
546
+ const params = new URLSearchParams({ network: workspaceId });
547
+ if (channelName) params.set('channel', channelName);
548
+ if (agent) params.set('agent', agent);
549
+ if (all) params.set('all', 'true');
550
+ const data = await this._get(`/v1/todos?${params}`, this._wsHeaders(token));
551
+ return data.data || data;
552
+ }
553
+
554
+ async createTimer(workspaceId, channelName, token, delay, message, { source } = {}) {
555
+ const body = {
556
+ delay,
557
+ message,
558
+ network: workspaceId,
559
+ channel: channelName,
560
+ source: source || 'openagents:unknown',
561
+ };
562
+ const data = await this._post('/v1/timers', body, this._wsHeaders(token));
563
+ return data.data || data;
564
+ }
565
+
566
+ async listTimers(workspaceId, channelName, token) {
567
+ const params = new URLSearchParams({ network: workspaceId });
568
+ if (channelName) params.set('channel', channelName);
569
+ const data = await this._get(`/v1/timers?${params}`, this._wsHeaders(token));
570
+ return data.data || data;
571
+ }
572
+
573
+ async cancelTimer(workspaceId, token, timerId, network) {
574
+ const params = network ? `?network=${network}` : '';
575
+ const data = await this._delete(`/v1/timers/${timerId}`, this._wsHeaders(token));
576
+ return data.data || data;
577
+ }
578
+
527
579
  // ── Internal helpers ──
528
580
 
529
581
  /**
@@ -669,6 +721,48 @@ class WorkspaceClient {
669
721
  });
670
722
  }
671
723
 
724
+ _put(urlPath, body, headers = {}, timeout = 30000) {
725
+ if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
726
+ const jsonBody = JSON.stringify(body);
727
+ const fullUrl = this.endpoint + urlPath;
728
+
729
+ return new Promise((resolve, reject) => {
730
+ const parsedUrl = new URL(fullUrl);
731
+ const transport = parsedUrl.protocol === 'https:' ? https : http;
732
+
733
+ const req = transport.request(fullUrl, {
734
+ method: 'PUT',
735
+ headers: { ...headers, 'Content-Length': Buffer.byteLength(jsonBody) },
736
+ timeout,
737
+ }, (res) => {
738
+ let data = '';
739
+ res.on('data', (chunk) => { data += chunk; });
740
+ res.on('end', () => {
741
+ try {
742
+ const parsed = JSON.parse(data);
743
+ if (res.statusCode >= 400) {
744
+ const msg = parsed.message || `HTTP ${res.statusCode}`;
745
+ if (typeof msg === 'string' && msg.toLowerCase().includes('session_revoked')) {
746
+ reject(new SessionRevokedError(msg));
747
+ } else {
748
+ reject(new Error(msg));
749
+ }
750
+ } else {
751
+ resolve(parsed);
752
+ }
753
+ } catch {
754
+ reject(new Error(`Invalid response: ${data.slice(0, 200)}`));
755
+ }
756
+ });
757
+ });
758
+
759
+ req.on('error', reject);
760
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
761
+ req.write(jsonBody);
762
+ req.end();
763
+ });
764
+ }
765
+
672
766
  _patch(urlPath, body, headers = {}, timeout = 15000) {
673
767
  if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
674
768
  const jsonBody = JSON.stringify(body);