@openagents-org/agent-launcher 0.2.115 → 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.115",
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();
@@ -128,6 +130,48 @@ class ClaudeAdapter extends BaseAdapter {
128
130
  } catch {}
129
131
  }
130
132
 
133
+ /**
134
+ * Build a short transcript of the channel's last chat exchanges, used to
135
+ * re-seed context when --resume fails and we have to start a fresh
136
+ * Claude Code session. Returns null when there's nothing useful to add.
137
+ *
138
+ * Excludes the user's current message (the for-loop will append it
139
+ * normally) and any status/thinking events, which are mostly tool-call
140
+ * noise and inflate the prompt without adding signal.
141
+ */
142
+ async _buildChannelRecap(channelName, currentMessage) {
143
+ const messages = await this.client.getRecentMessages(
144
+ this.workspaceId, channelName, this.token, 30
145
+ );
146
+ if (!messages || messages.length === 0) return null;
147
+
148
+ const lines = [];
149
+ for (const m of messages) {
150
+ const mt = m.messageType || 'chat';
151
+ if (mt === 'status' || mt === 'thinking' || mt === 'loading') continue;
152
+ const text = (m.content || '').trim();
153
+ if (!text) continue;
154
+ // Don't echo the user's current message back at them.
155
+ if (text === currentMessage) continue;
156
+ const who = m.senderType === 'human'
157
+ ? (m.senderName || 'user')
158
+ : (m.senderName || 'agent');
159
+ // Cap each line so a single huge paste doesn't blow up the prompt.
160
+ const truncated = text.length > 800 ? text.slice(0, 800) + '…' : text;
161
+ lines.push(`[${who}] ${truncated}`);
162
+ }
163
+ if (lines.length === 0) return null;
164
+
165
+ // Keep only the tail; older context has diminishing value and we
166
+ // don't want to balloon the system prompt.
167
+ const tail = lines.slice(-15).join('\n');
168
+ return (
169
+ 'You previously worked in this channel but your prior session is no ' +
170
+ 'longer available, so here is the recent conversation for context:\n\n' +
171
+ tail
172
+ );
173
+ }
174
+
131
175
  async _stopAllProcesses(completionMessage = 'Execution stopped.') {
132
176
  const entries = Object.entries(this._channelProcesses);
133
177
  if (!entries.length) return;
@@ -243,15 +287,81 @@ class ClaudeAdapter extends BaseAdapter {
243
287
  throw new Error('claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash');
244
288
  }
245
289
 
246
- const systemPrompt = '\n' + buildClaudeSystemPrompt({
290
+ let systemPrompt = '\n' + buildClaudeSystemPrompt({
247
291
  agentName: this.agentName,
248
292
  workspaceId: this.workspaceId,
249
293
  channelName,
250
294
  mode: this._mode,
251
295
  });
252
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
+
253
309
  const cmd = [claudeBin, '-p', prompt, '--output-format', 'stream-json', '--verbose'];
254
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) {
255
365
  // Mode-dependent permission and tool flags
256
366
  const pfx = 'mcp__openagents-workspace__';
257
367
  const mcpTools = [
@@ -284,23 +394,16 @@ class ClaudeAdapter extends BaseAdapter {
284
394
  mcpWriteTools.push(`${pfx}tunnel_expose`, `${pfx}tunnel_close`);
285
395
  }
286
396
 
287
- 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
+
288
401
  if (this._mode === 'plan') {
289
402
  cmd.push('--permission-mode', 'plan');
290
- allowed = [...mcpTools, 'Read', 'Glob', 'Grep'];
403
+ cmd.push('--allowedTools', ...mcpTools, 'Read', 'Glob', 'Grep');
291
404
  } else {
292
405
  cmd.push('--dangerously-skip-permissions');
293
- allowed = [...mcpTools, ...mcpWriteTools, 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'];
294
- }
295
-
296
- cmd.push('--append-system-prompt', systemPrompt);
297
- cmd.push('--allowedTools', ...allowed);
298
- cmd.push('--disallowedTools', 'AskUserQuestion');
299
-
300
- // Resume existing conversation (skipped on retry after stale session)
301
- const sessionId = this._channelSessions[channelName];
302
- if (sessionId && !skipResume) {
303
- cmd.push('--resume', sessionId);
406
+ cmd.push('--allowedTools', ...mcpTools, ...mcpWriteTools, 'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep');
304
407
  }
305
408
 
306
409
  // MCP config for workspace tools
@@ -314,18 +417,13 @@ class ClaudeAdapter extends BaseAdapter {
314
417
  if (this.disabledModules.has('files')) mcpArgs.push('--disable-files');
315
418
  if (this.disabledModules.has('browser')) mcpArgs.push('--disable-browser');
316
419
 
317
- // Resolve the MCP server entry point. Prefer the sibling bin inside this
318
- // very package — it's guaranteed to exist whenever claude.js is executing,
319
- // so it never falls through to a broken PATH lookup. If Claude Code can't
320
- // spawn the MCP server, it silently hides every workspace tool and the
321
- // agent reports "workspace_read_file isn't in my tool set".
420
+ // Resolve the MCP server entry point
322
421
  let mcpCommand = this._findNodeBin();
323
422
  let mcpFinalArgs = mcpArgs;
324
423
  const siblingBin = path.resolve(__dirname, '..', '..', 'bin', 'agent-connector.js');
325
424
  if (fs.existsSync(siblingBin)) {
326
425
  mcpFinalArgs = [siblingBin, ...mcpArgs];
327
426
  } else {
328
- // Fallback: search installed locations (older layouts, global installs)
329
427
  let oaBin = null;
330
428
  const home3 = os.homedir();
331
429
  const oaExt = IS_WINDOWS ? '.cmd' : '';
@@ -433,26 +531,43 @@ class ClaudeAdapter extends BaseAdapter {
433
531
  let mcpConfigFile = null;
434
532
  let cmd;
435
533
 
436
- // Clean env: strip every CLAUDE_* / AI_AGENT variable inherited from a
437
- // parent Claude Code (or Claude Agent SDK) process. If we don't, the
438
- // spawned `claude` thinks it's running under an SDK harness and picks
439
- // an org-scoped auth path that returns 403 "Account is no longer a
440
- // member of the organization" even when the user is logged in fine via
441
- // `claude login`. We let the child rediscover auth from
442
- // ~/.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
+ ]);
443
545
  const cleanEnv = { ...(this.agentEnv || process.env) };
444
546
  for (const k of Object.keys(cleanEnv)) {
445
- if (k.startsWith('CLAUDE_') || k === 'CLAUDECODE' || k === 'AI_AGENT') {
547
+ if ((k.startsWith('CLAUDE_') && !CLAUDE_ENV_KEEP.has(k)) || k === 'CLAUDECODE' || k === 'AI_AGENT') {
446
548
  delete cleanEnv[k];
447
549
  }
448
550
  }
449
551
 
450
552
  // Run up to 2 attempts: first with session resume, then fresh if stale session detected
451
553
  let _shouldRetry = false;
554
+ let effectiveContent = content;
452
555
  for (let attempt = 0; attempt < 2; attempt++) {
453
556
  if (mcpConfigFile) { try { fs.unlinkSync(mcpConfigFile); } catch {} mcpConfigFile = null; }
557
+
558
+ // On the retry pass after a stale --resume, the spawned `claude`
559
+ // starts a brand-new session with no memory of prior turns. Replay
560
+ // the channel's recent chat history so the agent at least has a
561
+ // recap instead of saying "I don't see any previous messages."
562
+ if (attempt > 0) {
563
+ try {
564
+ const recap = await this._buildChannelRecap(msgChannel, content);
565
+ if (recap) effectiveContent = `${recap}\n\n---\n\n${content}`;
566
+ } catch {}
567
+ }
568
+
454
569
  try {
455
- const built = this._buildClaudeCmd(content, msgChannel, { skipResume: attempt > 0 });
570
+ const built = this._buildClaudeCmd(effectiveContent, msgChannel, { skipResume: attempt > 0 });
456
571
  cmd = built.cmd;
457
572
  mcpConfigFile = built.mcpConfigFile;
458
573
  } catch (e) {
@@ -483,6 +598,7 @@ class ClaudeAdapter extends BaseAdapter {
483
598
  const lastResponseText = [];
484
599
  let hasToolUseSinceLastText = false;
485
600
  let postedThinking = false;
601
+ let everPostedAnything = false;
486
602
  let stderrBuf = '';
487
603
  let lineBuffer = '';
488
604
  let _pendingLines = Promise.resolve(); // chain of in-flight processLine calls
@@ -542,6 +658,7 @@ class ClaudeAdapter extends BaseAdapter {
542
658
  }
543
659
  lastResponseText.push(block.text.trim());
544
660
  postedThinking = true;
661
+ everPostedAnything = true;
545
662
  // Stream text in real-time as "thinking" (same as Python adapter)
546
663
  try { await this.sendThinking(msgChannel, block.text.trim()); } catch {}
547
664
  } else if (block.type === 'tool_use') {
@@ -549,6 +666,7 @@ class ClaudeAdapter extends BaseAdapter {
549
666
  postedThinking = false;
550
667
  lastResponseText.length = 0;
551
668
  const toolName = block.name || '';
669
+
552
670
  // Format tool input as readable text
553
671
  let inputPreview = '';
554
672
  if (block.input && typeof block.input === 'object') {
@@ -565,6 +683,7 @@ class ClaudeAdapter extends BaseAdapter {
565
683
  inputPreview = String(block.input || '').slice(0, 150);
566
684
  }
567
685
  await this.sendStatus(msgChannel, `${toolName} › ${inputPreview}`);
686
+ everPostedAnything = true;
568
687
  }
569
688
  }
570
689
  } else if (eventType === 'result') {
@@ -600,6 +719,10 @@ class ClaudeAdapter extends BaseAdapter {
600
719
  }
601
720
 
602
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
+
603
726
  const stoppedByUser = this._stoppingChannels.has(msgChannel);
604
727
  if (stoppedByUser) {
605
728
  this._stoppingChannels.delete(msgChannel);
@@ -607,8 +730,8 @@ class ClaudeAdapter extends BaseAdapter {
607
730
  return;
608
731
  }
609
732
 
733
+ this._log(`CLI exited: code=${code}, lastResponseText=${lastResponseText.length} items, everPosted=${everPostedAnything}, hasSession=${!!this._channelSessions[msgChannel]}`);
610
734
  if (code !== 0) {
611
- this._log(`CLI exited with code ${code}`);
612
735
  if (stderrBuf.trim()) {
613
736
  this._log(`stderr: ${stderrBuf.trim().slice(0, 500)}`);
614
737
  }
@@ -620,14 +743,16 @@ class ClaudeAdapter extends BaseAdapter {
620
743
  try { await this.sendResponse(msgChannel, fullResponse); } catch {}
621
744
  }
622
745
  resolve(false); // done, no retry
623
- } else if (code !== 0 && this._channelSessions[msgChannel]) {
624
- // 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.
625
750
  this._log(`Stale session detected for ${msgChannel}, clearing and retrying without resume`);
626
751
  delete this._channelSessions[msgChannel];
627
752
  this._saveSessions();
628
753
  resolve(true); // retry=true
629
754
  } else {
630
- if (!postedThinking) {
755
+ if (!everPostedAnything) {
631
756
  try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
632
757
  }
633
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
  }
@@ -191,6 +191,32 @@ class WorkspaceClient {
191
191
  return events.map((e) => this._eventToMessage(e));
192
192
  }
193
193
 
194
+ /**
195
+ * Fetch the most recent N messages in a channel, returned oldest-to-newest.
196
+ * Used by adapters to rebuild context for a fresh Claude Code session
197
+ * when --resume of the previous session fails (the channel's chat history
198
+ * is the only thing that survives a session-storage rotation).
199
+ */
200
+ async getRecentMessages(workspaceId, channelName, token, limit = 30) {
201
+ try {
202
+ const params = new URLSearchParams({
203
+ network: workspaceId,
204
+ channel: channelName,
205
+ type: 'workspace.message',
206
+ sort: 'desc',
207
+ limit: String(limit),
208
+ });
209
+ const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
210
+ const result = data.data || data;
211
+ const events = (result && result.events) || [];
212
+ // Server returned newest-first; reverse so the caller can present them
213
+ // in chronological order without further fiddling.
214
+ return events.slice().reverse().map((e) => this._eventToMessage(e));
215
+ } catch {
216
+ return [];
217
+ }
218
+ }
219
+
194
220
  /**
195
221
  * Fetch the latest workspace.message.posted event id (head cursor).
196
222
  * Used by adapters to skip past existing events on join in O(1) instead
@@ -267,6 +293,11 @@ class WorkspaceClient {
267
293
  // Legacy server (no target_agents): broadcast for compat
268
294
  messages.push(this._eventToMessage(e));
269
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
+ }
270
301
  } else if (source.startsWith('openagents:')) {
271
302
  // Agent messages: only pick up if explicitly listed
272
303
  if (hasTargetList && targetAgents.includes(agentName)) {
@@ -498,6 +529,53 @@ class WorkspaceClient {
498
529
  return data.data || data;
499
530
  }
500
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
+
501
579
  // ── Internal helpers ──
502
580
 
503
581
  /**
@@ -643,6 +721,48 @@ class WorkspaceClient {
643
721
  });
644
722
  }
645
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
+
646
766
  _patch(urlPath, body, headers = {}, timeout = 15000) {
647
767
  if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
648
768
  const jsonBody = JSON.stringify(body);