@openagents-org/agent-launcher 0.2.119 → 0.2.121

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/icons/kimi.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg viewBox="0 0 24 24" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="kimi-g" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#141821"/>
5
+ <stop offset="0.52" stop-color="#2f6df6"/>
6
+ <stop offset="1" stop-color="#49d2ff"/>
7
+ </linearGradient>
8
+ </defs>
9
+ <rect x="2" y="2" width="20" height="20" rx="6" fill="url(#kimi-g)"/>
10
+ <path d="M7 6.5h2.6v4.1l4.2-4.1H17l-4.7 4.7 5 6.3h-3.2l-3.6-4.7-.9.9v3.8H7z" fill="#fff"/>
11
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.119",
3
+ "version": "0.2.121",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/registry.json CHANGED
@@ -557,6 +557,65 @@
557
557
  "windows": "pip install sweagent"
558
558
  }
559
559
  },
560
+ {
561
+ "name": "kimi",
562
+ "label": "Kimi",
563
+ "description": "Kimi agent powered by Moonshot AI, OpenAI-compatible API.",
564
+ "homepage": "https://platform.moonshot.ai",
565
+ "tags": [
566
+ "coding",
567
+ "moonshot",
568
+ "open-source"
569
+ ],
570
+ "featured": true,
571
+ "order": 7,
572
+ "support": { "install": true, "workspace": true, "collaboration": true },
573
+ "builtin": true,
574
+ "install": {
575
+ "binary": "kimi",
576
+ "api_only": true,
577
+ "macos": "echo 'Kimi uses direct API mode — no binary install needed'",
578
+ "linux": "echo 'Kimi uses direct API mode — no binary install needed'",
579
+ "windows": "echo 'Kimi uses direct API mode — no binary install needed'"
580
+ },
581
+ "adapter": {
582
+ "module": "openagents.adapters.kimi",
583
+ "class": "KimiAdapter"
584
+ },
585
+ "launch": {
586
+ "args": []
587
+ },
588
+ "env_config": [
589
+ {
590
+ "name": "KIMI_API_KEY",
591
+ "description": "Moonshot/Kimi API key (also accepts MOONSHOT_API_KEY env var)",
592
+ "required": true,
593
+ "password": true
594
+ },
595
+ {
596
+ "name": "KIMI_BASE_URL",
597
+ "description": "Kimi API base URL (OpenAI-compatible endpoint)",
598
+ "required": false,
599
+ "default": "https://api.moonshot.ai/v1",
600
+ "placeholder": "https://api.moonshot.ai/v1"
601
+ },
602
+ {
603
+ "name": "KIMI_MODEL",
604
+ "description": "Kimi model name",
605
+ "required": false,
606
+ "default": "kimi-k2.6",
607
+ "placeholder": "kimi-k2.6"
608
+ }
609
+ ],
610
+ "check_ready": {
611
+ "env_vars": [
612
+ "KIMI_API_KEY",
613
+ "MOONSHOT_API_KEY"
614
+ ],
615
+ "saved_env_key": "KIMI_API_KEY",
616
+ "not_ready_message": "No API key — press e to configure"
617
+ }
618
+ },
560
619
  {
561
620
  "name": "hermes",
562
621
  "label": "Hermes Agent",
@@ -52,6 +52,11 @@ class BaseAdapter {
52
52
  // Per-channel task tracking for parallel execution
53
53
  this._channelBusy = new Set();
54
54
  this._channelQueues = {};
55
+ // Wall-clock timestamp of adapter init, used by the `status` control
56
+ // action to report uptime back to the channel. Reset on reinstantiation
57
+ // (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
58
+ // restart" rather than the long-running daemon's process uptime.
59
+ this._startedAt = Date.now();
55
60
  this._log = (msg) => {
56
61
  const ts = new Date().toISOString();
57
62
  console.log(`${ts} INFO adapter [${this.agentName}]: ${msg}`);
@@ -79,8 +84,12 @@ class BaseAdapter {
79
84
  this._log(`Warning: join failed: ${e.message} \nStack: ${e.stack}`);
80
85
  }
81
86
 
82
- await this._skipExistingEvents();
83
-
87
+ // Fast-path operations (control-event cursor + heartbeat + control poll)
88
+ // run BEFORE the message-cursor advance. Even though _skipExistingEvents
89
+ // is fast on a healthy backend, we don't want slash commands gated on
90
+ // its success — keeping these paths independent makes /restart and
91
+ // /status responsive immediately after join.
92
+ await this._skipExistingControlEvents();
84
93
  const heartbeatInterval = setInterval(() => this._heartbeat(), 30000);
85
94
  const controlPoller = this._controlPollerLoop();
86
95
 
@@ -89,6 +98,8 @@ class BaseAdapter {
89
98
  try { await this._heartbeat(); } catch (e) {
90
99
  this._log(`Heartbeat failed (non-fatal): ${e.message}`);
91
100
  }
101
+ // Slow path: only the message-poll loop waits for this.
102
+ await this._skipExistingEvents();
92
103
  this._log('Starting poll loop...');
93
104
  await this._pollLoop();
94
105
  } finally {
@@ -144,6 +155,26 @@ class BaseAdapter {
144
155
  // Control polling
145
156
  // ------------------------------------------------------------------
146
157
 
158
+ /**
159
+ * Advance `_lastControlId` past any pending control events for this agent
160
+ * so we don't re-process them after a respawn. Without this, /restart
161
+ * triggers a daemon bounce, the new adapter starts with _lastControlId=null,
162
+ * polls and re-finds the same /restart event, bounces again — restart loop.
163
+ */
164
+ async _skipExistingControlEvents() {
165
+ try {
166
+ const events = await this.client.pollControl(
167
+ this.workspaceId, this.agentName, this.token,
168
+ { after: null }
169
+ );
170
+ if (events.length > 0) {
171
+ // pollControl returns ascending-by-timestamp; take the latest.
172
+ this._lastControlId = events[events.length - 1].id;
173
+ this._log(`Skipped ${events.length} existing control event(s), cursor at ${this._lastControlId}`);
174
+ }
175
+ } catch {}
176
+ }
177
+
147
178
  async _pollControl() {
148
179
  try {
149
180
  const events = await this.client.pollControl(
@@ -169,9 +200,66 @@ class BaseAdapter {
169
200
  }
170
201
 
171
202
  /**
172
- * Handle adapter-specific control actions. Override in subclasses.
203
+ * Handle adapter-specific control actions. Override in subclasses to add
204
+ * per-adapter actions (`stop`, `restart`, …); always call
205
+ * `await super._onControlAction(action, payload)` from the override for
206
+ * actions you don't recognize, so shared actions like `status` keep
207
+ * working uniformly across adapter types.
208
+ */
209
+ async _onControlAction(action, payload) {
210
+ if (action === 'status') {
211
+ await this._postStatusReport(payload);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Post a chat message back to the requesting channel summarizing agent
217
+ * name, type, agent-launcher version, uptime, and network. Used by the
218
+ * `/status` slash command.
173
219
  */
174
- async _onControlAction(_action, _payload) {}
220
+ async _postStatusReport(payload) {
221
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
222
+ if (!channel) return;
223
+
224
+ let pkgVersion = 'unknown';
225
+ try {
226
+ const path = require('path');
227
+ const pkg = require(path.join(__dirname, '..', '..', 'package.json'));
228
+ pkgVersion = pkg.version || 'unknown';
229
+ } catch {}
230
+
231
+ const uptimeMs = Math.max(0, Date.now() - this._startedAt);
232
+ const totalSec = Math.floor(uptimeMs / 1000);
233
+ const days = Math.floor(totalSec / 86400);
234
+ const hours = Math.floor((totalSec % 86400) / 3600);
235
+ const minutes = Math.floor((totalSec % 3600) / 60);
236
+ const seconds = totalSec % 60;
237
+ let uptime;
238
+ if (days > 0) uptime = `${days}d ${hours}h ${minutes}m`;
239
+ else if (hours > 0) uptime = `${hours}h ${minutes}m`;
240
+ else if (minutes > 0) uptime = `${minutes}m ${seconds}s`;
241
+ else uptime = `${seconds}s`;
242
+
243
+ const adapterType = this.agentType || 'unknown';
244
+ const content =
245
+ `**Agent status**\n` +
246
+ `- Name: \`${this.agentName}\` (${adapterType})\n` +
247
+ `- Version: agent-launcher \`${pkgVersion}\`\n` +
248
+ `- Uptime: ${uptime}\n` +
249
+ `- Network: \`${this.workspaceId}\``;
250
+
251
+ try {
252
+ await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
253
+ senderType: 'agent',
254
+ senderName: this.agentName,
255
+ messageType: 'chat',
256
+ metadata: { agent_mode: this._mode },
257
+ sessionId: this._sessionId,
258
+ });
259
+ } catch (e) {
260
+ this._log(`Status: failed to post: ${e && e.message ? e.message : e}`);
261
+ }
262
+ }
175
263
 
176
264
  _hasActiveWork() {
177
265
  return this._channelBusy.size > 0;
@@ -412,6 +500,18 @@ class BaseAdapter {
412
500
  }
413
501
  }
414
502
 
503
+ async getRemainingTodos(channel) {
504
+ try {
505
+ const result = await this.client.getTodos(this.workspaceId, channel, this.token, {
506
+ all: false,
507
+ });
508
+ const todos = (result && result.todos) || [];
509
+ return todos.filter((t) => t.status === 'pending' || t.status === 'in_progress');
510
+ } catch {
511
+ return [];
512
+ }
513
+ }
514
+
415
515
  async sendTodos(channel, todos) {
416
516
  try {
417
517
  await this.client.putTodos(this.workspaceId, channel, this.token, todos, {
@@ -65,10 +65,87 @@ class ClaudeAdapter extends BaseAdapter {
65
65
  } catch {}
66
66
  }
67
67
 
68
- async _onControlAction(action, _payload) {
68
+ async _onControlAction(action, payload) {
69
69
  if (action === 'stop') {
70
- await this._stopAllProcesses('Execution stopped by user.');
70
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
71
+ if (channel && this._channelProcesses[channel]) {
72
+ this._log(`Stopping process for channel=${channel}`);
73
+ this._stoppingChannels.add(channel);
74
+ const proc = this._channelProcesses[channel];
75
+ await this._stopProcess(proc);
76
+ delete this._channelProcesses[channel];
77
+ delete this._channelQueues[channel];
78
+ try {
79
+ await this.sendResponse(channel, 'Execution stopped by user.');
80
+ } catch {}
81
+ } else {
82
+ await this._stopAllProcesses('Execution stopped by user.');
83
+ }
84
+ return;
85
+ }
86
+ if (action === 'restart') {
87
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
88
+ if (channel) {
89
+ // Kill in-flight subprocess + clear the per-channel session BEFORE
90
+ // asking the daemon to bounce us. The new adapter spawned after
91
+ // the bounce loads sessions from disk, so the cleared state must
92
+ // be persisted first.
93
+ const proc = this._channelProcesses[channel];
94
+ if (proc) {
95
+ try { await this._stopProcess(proc); } catch {}
96
+ delete this._channelProcesses[channel];
97
+ }
98
+ if (this._channelSessions[channel]) {
99
+ delete this._channelSessions[channel];
100
+ try { this._saveSessions(); } catch {}
101
+ this._log(`Restart: cleared session for channel=${channel}`);
102
+ } else {
103
+ this._log(`Restart: no session to clear for channel=${channel}`);
104
+ }
105
+ // Post the status BEFORE the bounce so the message lands while
106
+ // we're still online.
107
+ try {
108
+ await this.client.sendMessage(this.workspaceId, channel, this.token,
109
+ 'Session restarted — next message starts fresh.',
110
+ {
111
+ senderType: 'agent',
112
+ senderName: this.agentName,
113
+ messageType: 'status',
114
+ metadata: { agent_mode: this._mode },
115
+ sessionId: this._sessionId,
116
+ });
117
+ } catch (e) {
118
+ this._log(`Restart: failed to post status: ${e && e.message ? e.message : e}`);
119
+ }
120
+ } else {
121
+ // Defensive — no channel, clear everything before the bounce.
122
+ this._channelSessions = {};
123
+ try { this._saveSessions(); } catch {}
124
+ await this._stopAllProcesses('Execution stopped by user.');
125
+ this._log('Restart: cleared all sessions (no channel param)');
126
+ }
127
+ // Ask the daemon to bounce just THIS agent — true process-level
128
+ // restart. Daemon's command-file poller picks up `restart:<name>`
129
+ // within ~1s, calls restartAgent, our run() loop exits cleanly,
130
+ // and a fresh adapter is spawned with a new `_startedAt`. Sibling
131
+ // agents on the same daemon are untouched.
132
+ try {
133
+ const path = require('path');
134
+ const os = require('os');
135
+ const fs = require('fs');
136
+ const cmdFile = path.join(os.homedir(), '.openagents', 'daemon.cmd');
137
+ fs.writeFileSync(cmdFile, `restart:${this.agentName}\n`);
138
+ this._log(`Restart: requested daemon bounce for agent=${this.agentName}`);
139
+ } catch (e) {
140
+ this._log(`Restart: failed to write daemon.cmd: ${e && e.message ? e.message : e}`);
141
+ // Fallback: reset uptime in-place so the next /status reflects
142
+ // SOMETHING changed even if the daemon bounce didn't happen.
143
+ this._startedAt = Date.now();
144
+ }
145
+ return;
71
146
  }
147
+ // Fall through to base for shared actions (status, etc.).
148
+ await super._onControlAction(action, payload);
72
149
  }
73
150
 
74
151
  /**
@@ -301,7 +378,8 @@ class ClaudeAdapter extends BaseAdapter {
301
378
  'Use workspace_get_history to read previous messages.\n' +
302
379
  'Use workspace_get_agents to see other agents.\n',
303
380
  'Use the openagents-workspace skill (Bash + curl) for workspace operations:\n' +
304
- 'reading message history, discovering agents, sharing files, and browsing.\n' +
381
+ 'reading message history, discovering agents, sharing files, browsing,\n' +
382
+ 'managing to-do lists, setting timers, and creating routines.\n' +
305
383
  'Refer to the skill instructions for the exact curl commands.\n'
306
384
  );
307
385
  }
@@ -394,9 +472,9 @@ class ClaudeAdapter extends BaseAdapter {
394
472
  mcpWriteTools.push(`${pfx}tunnel_expose`, `${pfx}tunnel_close`);
395
473
  }
396
474
 
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`);
475
+ // Todos, Timers & Routines (always enabled)
476
+ mcpTools.push(`${pfx}workspace_get_todos`, `${pfx}workspace_list_timers`, `${pfx}workspace_list_routines`);
477
+ mcpWriteTools.push(`${pfx}workspace_put_todos`, `${pfx}workspace_create_timer`, `${pfx}workspace_cancel_timer`, `${pfx}workspace_create_routine`, `${pfx}workspace_cancel_routine`);
400
478
 
401
479
  if (this._mode === 'plan') {
402
480
  cmd.push('--permission-mode', 'plan');
@@ -720,10 +798,33 @@ class ClaudeAdapter extends BaseAdapter {
720
798
 
721
799
  delete this._channelProcesses[msgChannel];
722
800
 
723
- // Clean up stale todos: mark pending/in_progress as cancelled
724
- try { await this.cleanupTodos(msgChannel); } catch {}
725
-
726
801
  const stoppedByUser = this._stoppingChannels.has(msgChannel);
802
+
803
+ if (stoppedByUser) {
804
+ // User explicitly stopped — cancel all remaining todos
805
+ try { await this.cleanupTodos(msgChannel); } catch {}
806
+ } else if (!msg._todoNudge) {
807
+ // Normal exit (not already a nudge) — check for remaining pending todos
808
+ try {
809
+ const remaining = await this.getRemainingTodos(msgChannel);
810
+ if (remaining.length > 0) {
811
+ const items = remaining.map((t) => `- ${t.content}`).join('\n');
812
+ const nudge = `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`;
813
+ if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
814
+ this._channelQueues[msgChannel].push({
815
+ content: nudge,
816
+ senderType: 'system',
817
+ senderName: 'system:todos',
818
+ sessionId: msgChannel,
819
+ messageType: 'chat',
820
+ _todoNudge: true,
821
+ });
822
+ }
823
+ } catch {}
824
+ } else {
825
+ // Nudge response finished but todos still pending — cancel to avoid infinite loop
826
+ try { await this.cleanupTodos(msgChannel); } catch {}
827
+ }
727
828
  if (stoppedByUser) {
728
829
  this._stoppingChannels.delete(msgChannel);
729
830
  resolve(false);
@@ -739,10 +840,20 @@ class ClaudeAdapter extends BaseAdapter {
739
840
 
740
841
  if (lastResponseText.length > 0) {
741
842
  const fullResponse = lastResponseText.join('\n').trim();
742
- if (fullResponse) {
843
+ // "Prompt is too long" means the resumed session's context
844
+ // exceeded the model's limit. Clear the session and retry
845
+ // fresh with a bounded recap instead of the full history.
846
+ if (/prompt is too long/i.test(fullResponse) && this._channelSessions[msgChannel]) {
847
+ this._log(`Prompt too long with resumed session for ${msgChannel}, clearing and retrying`);
848
+ delete this._channelSessions[msgChannel];
849
+ this._saveSessions();
850
+ resolve(true);
851
+ } else if (fullResponse) {
743
852
  try { await this.sendResponse(msgChannel, fullResponse); } catch {}
853
+ resolve(false);
854
+ } else {
855
+ resolve(false);
744
856
  }
745
- resolve(false); // done, no retry
746
857
  } else if (this._channelSessions[msgChannel] && !everPostedAnything) {
747
858
  // No output + had a resume session → likely stale session, retry fresh.
748
859
  // Covers both error exits (code !== 0) and silent success (code === 0)
@@ -13,6 +13,7 @@ const NanoClawAdapter = require('./nanoclaw');
13
13
  const CursorAdapter = require('./cursor');
14
14
  const HermesAdapter = require('./hermes');
15
15
  const GeminiAdapter = require('./gemini');
16
+ const KimiAdapter = require('./kimi');
16
17
 
17
18
  const ADAPTER_MAP = {
18
19
  openclaw: OpenClawAdapter,
@@ -23,11 +24,12 @@ const ADAPTER_MAP = {
23
24
  cursor: CursorAdapter,
24
25
  hermes: HermesAdapter,
25
26
  gemini: GeminiAdapter,
27
+ kimi: KimiAdapter,
26
28
  };
27
29
 
28
30
  /**
29
31
  * Create an adapter instance for the given agent type.
30
- * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes)
32
+ * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi)
31
33
  * @param {object} opts - Adapter constructor options
32
34
  * @returns {BaseAdapter}
33
35
  */
@@ -49,6 +51,7 @@ module.exports = {
49
51
  CursorAdapter,
50
52
  HermesAdapter,
51
53
  GeminiAdapter,
54
+ KimiAdapter,
52
55
  createAdapter,
53
56
  ADAPTER_MAP,
54
57
  };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Kimi adapter — Moonshot AI OpenAI-compatible chat completions.
3
+ *
4
+ * Reuses LlmDirectAdapter's streaming chat-completions client, but:
5
+ * - reads KIMI_API_KEY / MOONSHOT_API_KEY (also accepts LLM_API_KEY / OPENAI_API_KEY)
6
+ * - reads KIMI_BASE_URL / LLM_BASE_URL / OPENAI_BASE_URL, defaulting to https://api.moonshot.ai/v1
7
+ * - reads KIMI_MODEL / LLM_MODEL, defaulting to kimi-k2.6
8
+ *
9
+ * Priority for every value: UI-saved env > process env > default.
10
+ * Stop / status / control flow is inherited from BaseAdapter.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const LlmDirectAdapter = require('./llm-direct');
16
+
17
+ const DEFAULT_BASE_URL = 'https://api.moonshot.ai/v1';
18
+ const DEFAULT_MODEL = 'kimi-k2.6';
19
+
20
+ class KimiAdapter extends LlmDirectAdapter {
21
+ constructor(opts) {
22
+ super({
23
+ ...opts,
24
+ adapterLabel: 'Kimi',
25
+ modelEnvVar: 'KIMI_MODEL',
26
+ suppressConfigLog: true,
27
+ });
28
+
29
+ const env = this.agentEnv || process.env;
30
+
31
+ const apiKey =
32
+ env.KIMI_API_KEY ||
33
+ env.MOONSHOT_API_KEY ||
34
+ env.LLM_API_KEY ||
35
+ env.OPENAI_API_KEY ||
36
+ '';
37
+
38
+ const baseUrl = (
39
+ env.KIMI_BASE_URL ||
40
+ env.LLM_BASE_URL ||
41
+ env.OPENAI_BASE_URL ||
42
+ DEFAULT_BASE_URL
43
+ ).replace(/\/$/, '');
44
+
45
+ const model =
46
+ env.KIMI_MODEL ||
47
+ env.LLM_MODEL ||
48
+ DEFAULT_MODEL;
49
+
50
+ this._apiKey = apiKey;
51
+ this._baseUrl = baseUrl;
52
+ this._model = model;
53
+ this._directMode = !!(this._apiKey && this._baseUrl);
54
+
55
+ if (this._directMode) {
56
+ this._log(`Kimi mode: ${this._baseUrl} model=${this._model}`);
57
+ } else {
58
+ this._log(
59
+ 'Kimi adapter started without API key. ' +
60
+ 'Set KIMI_API_KEY (or MOONSHOT_API_KEY) via the Launcher Configure screen.'
61
+ );
62
+ }
63
+ }
64
+ }
65
+
66
+ module.exports = KimiAdapter;
@@ -37,16 +37,36 @@ class LlmDirectAdapter extends BaseAdapter {
37
37
  this._model = env[this._modelEnvVar] || env.OPENCLAW_MODEL || '';
38
38
  this._directMode = !!(this._apiKey && this._baseUrl);
39
39
 
40
- if (this._directMode) {
41
- this._log(`Direct LLM mode: ${this._baseUrl} model=${this._model || 'gpt-4o'}`);
42
- } else {
43
- this._log(
44
- `${this._adapterLabel} adapter started without direct API config. ` +
45
- 'Set OPENAI_API_KEY + OPENAI_BASE_URL for direct mode.'
46
- );
40
+ if (!opts.suppressConfigLog) {
41
+ if (this._directMode) {
42
+ this._log(`Direct LLM mode: ${this._baseUrl} model=${this._model || 'gpt-4o'}`);
43
+ } else {
44
+ this._log(
45
+ `${this._adapterLabel} adapter started without direct API config. ` +
46
+ 'Set OPENAI_API_KEY + OPENAI_BASE_URL for direct mode.'
47
+ );
48
+ }
47
49
  }
48
50
 
49
51
  this._conversationHistory = [];
52
+ this._activeRequests = new Set();
53
+ }
54
+
55
+ stop() {
56
+ super.stop();
57
+ for (const req of this._activeRequests) {
58
+ try { req.destroy(new Error('LLM API request stopped')); } catch {}
59
+ }
60
+ this._activeRequests.clear();
61
+ }
62
+
63
+ async _onControlAction(action, _payload) {
64
+ if (action === 'stop') {
65
+ for (const req of this._activeRequests) {
66
+ try { req.destroy(new Error('LLM API request stopped')); } catch {}
67
+ }
68
+ this._activeRequests.clear();
69
+ }
50
70
  }
51
71
 
52
72
  _buildSystemPrompt(channelName) {
@@ -133,10 +153,14 @@ class LlmDirectAdapter extends BaseAdapter {
133
153
  headers: { ...headers, 'Content-Length': Buffer.byteLength(payload) },
134
154
  timeout: 300000,
135
155
  }, (res) => {
156
+ const cleanup = () => this._activeRequests.delete(req);
136
157
  if (res.statusCode !== 200) {
137
158
  let body = '';
138
159
  res.on('data', (c) => { body += c; });
139
- res.on('end', () => reject(new Error(`LLM API returned ${res.statusCode}: ${body.slice(0, 300)}`)));
160
+ res.on('end', () => {
161
+ cleanup();
162
+ reject(new Error(`LLM API returned ${res.statusCode}: ${body.slice(0, 300)}`));
163
+ });
140
164
  return;
141
165
  }
142
166
 
@@ -166,11 +190,20 @@ class LlmDirectAdapter extends BaseAdapter {
166
190
  }
167
191
  });
168
192
 
169
- res.on('end', () => resolve(fullText.trim()));
193
+ res.on('end', () => {
194
+ cleanup();
195
+ resolve(fullText.trim());
196
+ });
170
197
  });
171
198
 
172
- req.on('error', reject);
173
- req.on('timeout', () => { req.destroy(); reject(new Error('LLM API request timed out')); });
199
+ this._activeRequests.add(req);
200
+ req.on('error', (err) => {
201
+ this._activeRequests.delete(req);
202
+ reject(err);
203
+ });
204
+ req.on('timeout', () => {
205
+ req.destroy(new Error('LLM API request timed out'));
206
+ });
174
207
  req.write(payload);
175
208
  req.end();
176
209
  });
@@ -268,6 +268,34 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
268
268
  );
269
269
  }
270
270
 
271
+ // Routines (recurring scheduled tasks)
272
+ if (!isPlan) {
273
+ sections.push(
274
+ '\n### Routines (Recurring Tasks)\n\n' +
275
+ 'Create a recurring routine that fires on a schedule and posts a message ' +
276
+ 'to the channel, waking you to do the work. Great for daily standups, ' +
277
+ 'periodic reviews, scheduled checks.\n\n' +
278
+ '**Schedule:** Specify `hour` (0-23 UTC), `minute` (0-59), and optional ' +
279
+ '`days` array (0=Mon, 6=Sun). Omit `days` for every day.\n\n' +
280
+ '**Create a routine:**\n' +
281
+ `\`curl -s -X POST -H "${h}" -H "Content-Type: application/json" ` +
282
+ `${baseUrl}/v1/routines -d '{"name":"Daily PR Review","message":"Review open PRs",` +
283
+ `"hour":8,"minute":0,` +
284
+ `"network":"${workspaceId}","channel":"${channelName}",` +
285
+ `"source":"openagents:${agentName}"}'\`\n\n` +
286
+ '**Create a weekday-only routine (Mon-Fri):**\n' +
287
+ `\`curl -s -X POST -H "${h}" -H "Content-Type: application/json" ` +
288
+ `${baseUrl}/v1/routines -d '{"name":"Morning Standup","message":"Post standup summary",` +
289
+ `"hour":9,"minute":0,"days":[0,1,2,3,4],` +
290
+ `"network":"${workspaceId}","channel":"${channelName}",` +
291
+ `"source":"openagents:${agentName}"}'\`\n\n` +
292
+ '**List active routines:**\n' +
293
+ `\`curl -s -H "${h}" "${baseUrl}/v1/routines?network=${workspaceId}&channel=${channelName}"\`\n\n` +
294
+ '**Cancel a routine:**\n' +
295
+ `\`curl -s -X DELETE -H "${h}" ${baseUrl}/v1/routines/ROUTINE_ID\`\n`
296
+ );
297
+ }
298
+
271
299
  // Discovery
272
300
  sections.push(
273
301
  '\n### Discover Agents\n\n' +
@@ -287,7 +315,10 @@ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = '
287
315
  parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode));
288
316
  parts.push(
289
317
  'Use workspace_get_history to read previous messages.\n' +
290
- 'Use workspace_get_agents to see other agents.\n'
318
+ 'Use workspace_get_agents to see other agents.\n' +
319
+ 'Use workspace_put_todos to track your progress with a to-do list.\n' +
320
+ 'Use workspace_create_timer to set a reminder that wakes you up later.\n' +
321
+ 'Use workspace_create_routine to set up recurring scheduled tasks (e.g. daily reviews).\n'
291
322
  );
292
323
  parts.push(buildCollaborationPrompt());
293
324
 
package/src/daemon.js CHANGED
@@ -138,6 +138,18 @@ class Daemon {
138
138
  }
139
139
 
140
140
  await this.stopAgent(agentName);
141
+
142
+ // stopAgent only waits 5s for `_adapters[name]` to disappear, but
143
+ // graceful adapter shutdown can take longer (control-poller cleanup,
144
+ // disconnect, in-flight CLI subprocess kill). If the adapter is still
145
+ // there when we reach _launchAgent, the duplicate-launch guard bails
146
+ // and the agent stays stuck in 'stopped'. Wait up to 20s so the
147
+ // launch sees a clean slate.
148
+ for (let i = 0; i < 40; i++) {
149
+ if (!this._adapters || !this._adapters[agentName]) break;
150
+ await new Promise(r => setTimeout(r, 500));
151
+ }
152
+
141
153
  this._stoppedAgents.delete(agentName);
142
154
 
143
155
  // Reload config in case it changed
package/src/installer.js CHANGED
@@ -59,6 +59,14 @@ class Installer {
59
59
  const entry = this.registry.getEntry(agentType);
60
60
  const npmPkg = entry && entry.install ? entry.install.npm_package : null;
61
61
  const binary = entry && entry.install ? entry.install.binary : agentType;
62
+ if (entry?.install?.api_only) {
63
+ const installed = this._hasMarker(agentType);
64
+ return {
65
+ installed,
66
+ managed: installed,
67
+ location: installed ? 'api_only' : null,
68
+ };
69
+ }
62
70
  const installCmd = entry && entry.install ? this._getInstallCommand(entry.install) : null;
63
71
  let npmPkgFromCmd = null;
64
72
  if (!npmPkg && installCmd && installCmd.includes('npm install')) {
@@ -135,6 +143,29 @@ class Installer {
135
143
  * @returns {{ installed: boolean, binary: string|null, version: string|null }}
136
144
  */
137
145
  healthCheck(agentType) {
146
+ const entry = this.registry.getEntry(agentType);
147
+ if (entry?.install?.api_only) {
148
+ const info = this.getInstallInfo(agentType);
149
+ if (!info.installed) {
150
+ return {
151
+ installed: false,
152
+ binary: null,
153
+ version: null,
154
+ ready: false,
155
+ auth_mode: null,
156
+ execution_mode: 'unavailable',
157
+ message: 'Not installed',
158
+ };
159
+ }
160
+
161
+ return {
162
+ installed: true,
163
+ binary: null,
164
+ version: null,
165
+ ...this._evaluateReadiness(agentType, entry, null),
166
+ };
167
+ }
168
+
138
169
  const binary = this._whichBinary(agentType);
139
170
  if (!binary) {
140
171
  return {
@@ -148,7 +179,6 @@ class Installer {
148
179
  };
149
180
  }
150
181
 
151
- const entry = this.registry.getEntry(agentType);
152
182
  const checkCmd = entry && entry.install ? entry.install.check_command : null;
153
183
  const versionCmd = checkCmd || `${entry && entry.install && entry.install.binary || agentType} --version`;
154
184
 
@@ -323,6 +353,11 @@ class Installer {
323
353
  throw new Error(`No install definition for agent type: ${agentType}`);
324
354
  }
325
355
 
356
+ if (entry.install.api_only) {
357
+ this._markInstalled(agentType);
358
+ return { success: true, output: `${entry.label || agentType} uses direct API mode; no binary install needed.` };
359
+ }
360
+
326
361
  let cmd = this._getInstallCommand(entry.install);
327
362
  if (!cmd) {
328
363
  throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
@@ -354,6 +389,13 @@ class Installer {
354
389
  throw new Error(`No install definition for agent type: ${agentType}`);
355
390
  }
356
391
 
392
+ if (entry.install.api_only) {
393
+ if (onData) onData(`${entry.label || agentType} uses direct API mode; no binary install needed.\n`);
394
+ this._markInstalled(agentType);
395
+ if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
396
+ return { success: true, command: 'api-only' };
397
+ }
398
+
357
399
  let rawCmd = this._getInstallCommand(entry.install);
358
400
  if (!rawCmd) {
359
401
  throw new Error(`No install command for ${agentType} on ${Installer.platform()}`);
@@ -419,6 +461,11 @@ class Installer {
419
461
  throw new Error(`No install definition for agent type: ${agentType}`);
420
462
  }
421
463
 
464
+ if (entry.install.api_only) {
465
+ this._markUninstalled(agentType);
466
+ return { success: true, output: `${entry.label || agentType} API-only install marker removed.` };
467
+ }
468
+
422
469
  const installCmd = this._getInstallCommand(entry.install);
423
470
  const uninstallCmd = this._deriveUninstallCommand(installCmd, agentType);
424
471
  if (!uninstallCmd) {
@@ -462,6 +509,13 @@ class Installer {
462
509
  throw new Error(`No install definition for agent type: ${agentType}`);
463
510
  }
464
511
 
512
+ if (entry.install.api_only) {
513
+ if (onData) onData(`${entry.label || agentType} uses direct API mode; removing install marker.\n`);
514
+ this._markUninstalled(agentType);
515
+ if (onData) onData(`\nDone! ${agentType} has been uninstalled.\n`);
516
+ return { success: true, command: 'api-only' };
517
+ }
518
+
465
519
  const installCmd = this._getInstallCommand(entry.install);
466
520
  let rawCmd = this._deriveUninstallCommand(installCmd, agentType);
467
521
  if (!rawCmd) {
package/src/mcp-server.js CHANGED
@@ -299,6 +299,41 @@ function buildToolDefs(disabledModules) {
299
299
  required: ['timer_id'],
300
300
  },
301
301
  },
302
+ {
303
+ name: 'workspace_create_routine',
304
+ description: 'Create a recurring scheduled routine that posts a message on a repeating schedule.',
305
+ inputSchema: {
306
+ type: 'object',
307
+ properties: {
308
+ name: { type: 'string', description: 'Human-readable label for the routine (e.g. "Daily PR Review")' },
309
+ message: { type: 'string', description: 'Message to post each time the routine fires' },
310
+ hour: { type: 'integer', description: 'Hour in UTC (0-23)' },
311
+ minute: { type: 'integer', description: 'Minute (0-59)' },
312
+ days: {
313
+ type: 'array',
314
+ items: { type: 'integer' },
315
+ description: 'Days of week to fire (0=Mon, 6=Sun). Omit for every day.',
316
+ },
317
+ },
318
+ required: ['name', 'message', 'hour', 'minute'],
319
+ },
320
+ },
321
+ {
322
+ name: 'workspace_list_routines',
323
+ description: 'List active routines in the current channel.',
324
+ inputSchema: { type: 'object', properties: {} },
325
+ },
326
+ {
327
+ name: 'workspace_cancel_routine',
328
+ description: 'Cancel a recurring routine by its ID.',
329
+ inputSchema: {
330
+ type: 'object',
331
+ properties: {
332
+ routine_id: { type: 'string', description: 'Routine ID to cancel' },
333
+ },
334
+ required: ['routine_id'],
335
+ },
336
+ },
302
337
  );
303
338
 
304
339
  return tools;
@@ -712,6 +747,39 @@ class McpServer {
712
747
  return text(`Timer cancelled: ${args.timer_id}`);
713
748
  }
714
749
 
750
+ case 'workspace_create_routine': {
751
+ const result = await this.ws.createRoutine(
752
+ this.workspaceId, this.channelName, this.token,
753
+ {
754
+ name: args.name,
755
+ message: args.message,
756
+ hour: args.hour,
757
+ minute: args.minute,
758
+ days: args.days,
759
+ source: `openagents:${this.agentName}`,
760
+ },
761
+ );
762
+ const daysStr = args.days ? `days [${args.days.join(',')}]` : 'every day';
763
+ return text(`Routine created: "${args.name}" at ${String(args.hour).padStart(2,'0')}:${String(args.minute).padStart(2,'0')} UTC, ${daysStr} (id: ${result.id})`);
764
+ }
765
+
766
+ case 'workspace_list_routines': {
767
+ const data = await this.ws.listRoutines(this.workspaceId, this.channelName, this.token);
768
+ const routines = (data && data.routines) || [];
769
+ if (!routines.length) return text('No active routines.');
770
+ const lines = routines.map((r) =>
771
+ `- ${r.id}: "${r.name}" at ${String(r.schedule_hour).padStart(2,'0')}:${String(r.schedule_minute).padStart(2,'0')} UTC` +
772
+ (r.schedule_days ? ` [days: ${r.schedule_days.join(',')}]` : ' (daily)') +
773
+ ` — next: ${r.next_fires_at} (by ${r.created_by})`
774
+ );
775
+ return text(lines.join('\n'));
776
+ }
777
+
778
+ case 'workspace_cancel_routine': {
779
+ await this.ws.cancelRoutine(this.workspaceId, this.token, args.routine_id);
780
+ return text(`Routine cancelled: ${args.routine_id}`);
781
+ }
782
+
715
783
  default:
716
784
  throw new Error(`Unknown tool: ${name}`);
717
785
  }
package/src/utils.js CHANGED
@@ -12,11 +12,12 @@ function testLLMConnection(env) {
12
12
  const https = require('https');
13
13
  const http = require('http');
14
14
 
15
- const apiKey = env.LLM_API_KEY || env.OPENAI_API_KEY || env.ANTHROPIC_API_KEY || '';
15
+ const hasKimiConfig = !!(env.KIMI_API_KEY || env.MOONSHOT_API_KEY || env.KIMI_BASE_URL || env.KIMI_MODEL);
16
+ const apiKey = env.KIMI_API_KEY || env.MOONSHOT_API_KEY || env.LLM_API_KEY || env.OPENAI_API_KEY || env.ANTHROPIC_API_KEY || '';
16
17
  if (!apiKey) return Promise.resolve({ success: false, error: 'No API key provided' });
17
18
 
18
- let baseUrl = (env.LLM_BASE_URL || env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/$/, '');
19
- const model = env.LLM_MODEL || env.OPENCLAW_MODEL || '';
19
+ let baseUrl = (env.KIMI_BASE_URL || env.LLM_BASE_URL || env.OPENAI_BASE_URL || (hasKimiConfig ? 'https://api.moonshot.ai/v1' : 'https://api.openai.com/v1')).replace(/\/$/, '');
20
+ const model = env.KIMI_MODEL || env.LLM_MODEL || env.OPENCLAW_MODEL || '';
20
21
  const isAnthropic = baseUrl.includes('anthropic');
21
22
 
22
23
  if (!isAnthropic && !baseUrl.endsWith('/v1')) {
@@ -44,11 +45,13 @@ function testLLMConnection(env) {
44
45
  'Authorization': `Bearer ${apiKey}`,
45
46
  'Content-Type': 'application/json',
46
47
  };
47
- body = JSON.stringify({
48
- model: model || 'gpt-4o-mini',
49
- max_completion_tokens: 32,
48
+ const requestBody = {
49
+ model: model || (hasKimiConfig ? 'kimi-k2.6' : 'gpt-4o-mini'),
50
50
  messages: [{ role: 'user', content: 'Say hi in 5 words.' }],
51
- });
51
+ };
52
+ if (hasKimiConfig) requestBody.max_tokens = 32;
53
+ else requestBody.max_completion_tokens = 32;
54
+ body = JSON.stringify(requestBody);
52
55
  }
53
56
 
54
57
  const parsedUrl = new URL(url);
@@ -356,16 +356,17 @@ class WorkspaceClient {
356
356
  const params = new URLSearchParams({
357
357
  network: workspaceId,
358
358
  type: 'workspace.agent.control',
359
+ target: `openagents:${agentName}`,
359
360
  limit: '10',
361
+ sort: 'desc',
360
362
  });
361
363
  if (after) params.set('after', after);
362
364
  const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
363
365
  const result = data.data || data;
364
366
  const events = (result && result.events) || [];
365
- return events.filter((e) => {
366
- const target = e.target || '';
367
- return target === `openagents:${agentName}`;
368
- });
367
+ // Re-sort ascending by timestamp so callers process oldest-first.
368
+ events.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
369
+ return events;
369
370
  } catch {
370
371
  return [];
371
372
  }
@@ -576,6 +577,35 @@ class WorkspaceClient {
576
577
  return data.data || data;
577
578
  }
578
579
 
580
+ // ── Routines ──
581
+
582
+ async createRoutine(workspaceId, channelName, token, { name, message, hour, minute, days, source } = {}) {
583
+ const body = {
584
+ name,
585
+ message,
586
+ hour,
587
+ minute,
588
+ network: workspaceId,
589
+ channel: channelName,
590
+ source: source || 'openagents:unknown',
591
+ };
592
+ if (days) body.days = days;
593
+ const data = await this._post('/v1/routines', body, this._wsHeaders(token));
594
+ return data.data || data;
595
+ }
596
+
597
+ async listRoutines(workspaceId, channelName, token) {
598
+ const params = new URLSearchParams({ network: workspaceId });
599
+ if (channelName) params.set('channel', channelName);
600
+ const data = await this._get(`/v1/routines?${params}`, this._wsHeaders(token));
601
+ return data.data || data;
602
+ }
603
+
604
+ async cancelRoutine(workspaceId, token, routineId) {
605
+ const data = await this._delete(`/v1/routines/${routineId}`, this._wsHeaders(token));
606
+ return data.data || data;
607
+ }
608
+
579
609
  // ── Internal helpers ──
580
610
 
581
611
  /**