@openagents-org/agent-launcher 0.2.111 → 0.2.113

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.111",
3
+ "version": "0.2.113",
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
@@ -535,5 +535,37 @@
535
535
  "linux": "pip install sweagent",
536
536
  "windows": "pip install sweagent"
537
537
  }
538
+ },
539
+ {
540
+ "name": "hermes",
541
+ "label": "Hermes Agent",
542
+ "description": "Nous Hermes Agent — self-improving AI with tools, profiles, memory, and messaging",
543
+ "homepage": "https://github.com/NousResearch/hermes-agent",
544
+ "tags": [
545
+ "coding",
546
+ "tools",
547
+ "orchestration",
548
+ "profiles"
549
+ ],
550
+ "featured": true,
551
+ "order": 6,
552
+ "support": { "install": true, "workspace": true, "collaboration": true },
553
+ "builtin": true,
554
+ "install": {
555
+ "binary": "hermes",
556
+ "requires": ["python3"],
557
+ "macos": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
558
+ "linux": "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
559
+ "windows": "echo 'Hermes requires WSL2 on Windows — see https://github.com/NousResearch/hermes-agent'"
560
+ },
561
+ "launch": {
562
+ "args": []
563
+ },
564
+ "check_ready": {
565
+ "creds_file": "~/.hermes/config.yaml",
566
+ "status_command": "hermes status",
567
+ "login_command": "hermes setup",
568
+ "not_ready_message": "Hermes not configured — run: hermes setup"
569
+ }
538
570
  }
539
571
  ]
@@ -53,7 +53,7 @@ class BaseAdapter {
53
53
  this._channelQueues = {};
54
54
  this._log = (msg) => {
55
55
  const ts = new Date().toISOString();
56
- console.log(`${ts} INFO adapter: ${msg}`);
56
+ console.log(`${ts} INFO adapter [${this.agentName}]: ${msg}`);
57
57
  };
58
58
  }
59
59
 
@@ -109,20 +109,15 @@ class BaseAdapter {
109
109
  // ------------------------------------------------------------------
110
110
 
111
111
  async _skipExistingEvents() {
112
- try {
113
- while (true) {
114
- const { cursor } = await this.client.pollPending(
115
- this.workspaceId, this.agentName, this.token,
116
- { after: this._lastEventId, limit: 200 }
117
- );
118
- if (!cursor || cursor === this._lastEventId) break;
119
- this._lastEventId = cursor;
120
- }
121
- if (this._lastEventId) {
122
- this._log(`Skipped existing events, cursor at ${this._lastEventId}`);
123
- }
124
- } catch (e) {
125
- this._log(`Failed to skip existing events: ${e.message}`);
112
+ // Jump straight to the head with one server call. Pagination from the
113
+ // start was slow and brittle: on a busy workspace it could take many
114
+ // minutes to chew through historical events 200 at a time, leaving the
115
+ // agent silently behind, and a transient mid-paginate empty response
116
+ // (e.g. shared-cache race) would strand the cursor at a non-head id.
117
+ const head = await this.client.getHeadEventId(this.workspaceId, this.token);
118
+ if (head) {
119
+ this._lastEventId = head;
120
+ this._log(`Skipped existing events, cursor at ${head}`);
126
121
  }
127
122
  }
128
123
 
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Hermes adapter for OpenAgents workspace.
3
+ *
4
+ * Bridges Nous Research's Hermes Agent CLI (https://github.com/NousResearch/hermes-agent)
5
+ * to an OpenAgents workspace by spawning `hermes chat -q <prompt> -Q` per
6
+ * incoming message and posting the response back to the workspace channel.
7
+ *
8
+ * Mirrors the Python adapter at sdk/src/openagents/adapters/hermes.py:
9
+ * - per-channel Hermes session IDs persisted to ~/.openagents/sessions/
10
+ * - profile auto-detection from the agent name (falls back to 'default')
11
+ * - workspace context injection (identity + recent history + agent roster)
12
+ * - subprocess isolation (hermes manages its own HERMES_HOME per profile)
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const { execSync, spawn } = require('child_process');
21
+
22
+ const BaseAdapter = require('./base');
23
+ const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
24
+
25
+ const IS_WINDOWS = process.platform === 'win32';
26
+ const SESSION_ID_RE = /session_id:\s*(\S+)/;
27
+ const MAX_HISTORY_ENTRIES = 12;
28
+
29
+ class HermesAdapter extends BaseAdapter {
30
+ /**
31
+ * @param {object} opts - BaseAdapter opts plus:
32
+ * @param {string} [opts.hermesProfile] - explicit Hermes profile, or 'auto'
33
+ * @param {string} [opts.hermesSource] - `--source` label (default: 'tool')
34
+ * @param {number} [opts.maxTurns] - `--max-turns` value
35
+ * @param {boolean} [opts.yolo] - pass `--yolo` to skip prompts
36
+ * @param {Set} [opts.disabledModules]
37
+ */
38
+ constructor(opts) {
39
+ super(opts);
40
+ this.disabledModules = opts.disabledModules || new Set();
41
+ this.hermesProfile = this._resolveProfile(opts.hermesProfile, this.agentName);
42
+ this.hermesSource = opts.hermesSource || 'tool';
43
+ this.maxTurns = Number.isInteger(opts.maxTurns) ? opts.maxTurns : 60;
44
+ this.yolo = !!opts.yolo;
45
+
46
+ this._channelSessions = {};
47
+ this._channelProcesses = {};
48
+ this._sessionsFile = path.join(
49
+ os.homedir(), '.openagents', 'sessions',
50
+ `${this.workspaceId}_${this.agentName}_hermes.json`,
51
+ );
52
+ this._loadSessions();
53
+
54
+ this._hermesBin = this._findHermesBinary();
55
+ if (this._hermesBin) {
56
+ this._log(`Using Hermes binary: ${this._hermesBin} (profile=${this.hermesProfile})`);
57
+ } else {
58
+ this._log('Warning: hermes CLI not found. Install: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash');
59
+ }
60
+ }
61
+
62
+ // ------------------------------------------------------------------
63
+ // Binary discovery (multi-tier, matching codex/claude pattern)
64
+ // ------------------------------------------------------------------
65
+
66
+ _findHermesBinary() {
67
+ const home = os.homedir();
68
+
69
+ // Tier 1: PATH
70
+ try {
71
+ if (IS_WINDOWS) {
72
+ // Native Windows unsupported upstream — we try anyway for WSL cases
73
+ const r = execSync('where hermes 2>nul', { encoding: 'utf-8', timeout: 5000 });
74
+ const found = r.split(/\r?\n/)[0].trim();
75
+ if (found) return found;
76
+ } else {
77
+ const found = execSync('which hermes', { encoding: 'utf-8', timeout: 5000 }).trim();
78
+ if (found) return found;
79
+ }
80
+ } catch {}
81
+
82
+ // Tier 2: Common install locations (hermes installer uses ~/.local/bin)
83
+ const candidates = IS_WINDOWS ? [] : [
84
+ path.join(home, '.local', 'bin', 'hermes'),
85
+ '/opt/homebrew/bin/hermes',
86
+ '/usr/local/bin/hermes',
87
+ ];
88
+ for (const c of candidates) {
89
+ if (fs.existsSync(c)) return c;
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ _resolveProfile(explicit, agentName) {
96
+ if (explicit && explicit !== '' && explicit !== 'auto') return explicit;
97
+ // Match agent name to an existing ~/.hermes/profiles/<name> if present
98
+ try {
99
+ const profileDir = path.join(os.homedir(), '.hermes', 'profiles', agentName);
100
+ if (fs.existsSync(profileDir)) return agentName;
101
+ } catch {}
102
+ return 'default';
103
+ }
104
+
105
+ // ------------------------------------------------------------------
106
+ // Session persistence (per-channel Hermes session IDs)
107
+ // ------------------------------------------------------------------
108
+
109
+ _loadSessions() {
110
+ try {
111
+ if (fs.existsSync(this._sessionsFile)) {
112
+ const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
113
+ if (data && typeof data === 'object') {
114
+ Object.assign(this._channelSessions, data);
115
+ this._log(`Loaded ${Object.keys(data).length} Hermes session(s)`);
116
+ }
117
+ }
118
+ } catch {
119
+ this._log('Could not load Hermes sessions file, starting fresh');
120
+ }
121
+ }
122
+
123
+ _saveSessions() {
124
+ try {
125
+ fs.mkdirSync(path.dirname(this._sessionsFile), { recursive: true });
126
+ fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
127
+ } catch {}
128
+ }
129
+
130
+ // ------------------------------------------------------------------
131
+ // Prompt assembly
132
+ // ------------------------------------------------------------------
133
+
134
+ async _getAgentsText() {
135
+ try {
136
+ const agents = await this.client.getAgents(this.workspaceId, this.token);
137
+ if (!Array.isArray(agents) || agents.length === 0) return '';
138
+ const lines = agents
139
+ .map((a) => {
140
+ const name = a.agentName || a.agent_name || a.name;
141
+ if (!name) return null;
142
+ const role = a.role || 'member';
143
+ const status = a.status || 'unknown';
144
+ return `- ${name} (${role}, ${status})`;
145
+ })
146
+ .filter(Boolean);
147
+ return lines.length ? `## Available Workspace Agents\n${lines.join('\n')}` : '';
148
+ } catch {
149
+ return '';
150
+ }
151
+ }
152
+
153
+ async _getRecentHistoryText(channelName) {
154
+ try {
155
+ const messages = await this.client.pollMessages({
156
+ workspaceId: this.workspaceId,
157
+ channelName,
158
+ token: this.token,
159
+ limit: MAX_HISTORY_ENTRIES,
160
+ });
161
+ if (!Array.isArray(messages) || messages.length === 0) return '';
162
+ const lines = messages
163
+ .filter((m) => m.messageType !== 'status')
164
+ .map((m) => {
165
+ const sender = m.senderName || m.senderType || 'unknown';
166
+ const content = (m.content || '').trim();
167
+ if (!content) return null;
168
+ return `- ${sender}: ${content.slice(0, 400)}`;
169
+ })
170
+ .filter(Boolean);
171
+ return lines.length ? `## Recent Workspace Messages\n${lines.join('\n')}` : '';
172
+ } catch {
173
+ return '';
174
+ }
175
+ }
176
+
177
+ async _buildContextPrefix(channelName) {
178
+ const parts = [
179
+ buildOpenclawSystemPrompt({
180
+ agentName: this.agentName,
181
+ workspaceId: this.workspaceId,
182
+ channelName,
183
+ endpoint: this.endpoint,
184
+ token: this.token,
185
+ mode: this._mode,
186
+ disabledModules: this.disabledModules,
187
+ }),
188
+ '\n## OpenAgents-specific Rules',
189
+ '- Your final text response is posted back to the workspace automatically.',
190
+ '- If you need to ask the user something, ask in normal text. Do not try to open an interactive prompt.',
191
+ '- Do not reveal secrets, tokens, raw auth headers, or internal command lines.',
192
+ '- Keep status concise. Focus on useful output over theatre.',
193
+ ];
194
+
195
+ const [agentsText, historyText] = await Promise.all([
196
+ this._getAgentsText(),
197
+ this._getRecentHistoryText(channelName),
198
+ ]);
199
+ if (agentsText) parts.push('\n' + agentsText);
200
+ if (historyText) parts.push('\n' + historyText);
201
+ return parts.join('\n').trim();
202
+ }
203
+
204
+ // ------------------------------------------------------------------
205
+ // Output parsing
206
+ // ------------------------------------------------------------------
207
+
208
+ _parseHermesOutput(raw) {
209
+ let sessionId = null;
210
+ let body = raw;
211
+
212
+ const m = SESSION_ID_RE.exec(body);
213
+ if (m) {
214
+ sessionId = m[1];
215
+ body = body.replace(SESSION_ID_RE, '');
216
+ }
217
+
218
+ const lines = [];
219
+ for (const line of body.split(/\r?\n/)) {
220
+ const stripped = line.trim();
221
+ if (!stripped) continue;
222
+ if (stripped.startsWith('↻ Resumed session ')) continue;
223
+ lines.push(line);
224
+ }
225
+ return { text: lines.join('\n').trim(), sessionId };
226
+ }
227
+
228
+ // ------------------------------------------------------------------
229
+ // Subprocess lifecycle
230
+ // ------------------------------------------------------------------
231
+
232
+ _buildHermesCmd(prompt, resumeSessionId) {
233
+ if (!this._hermesBin) {
234
+ throw new Error('hermes CLI not found. Install with: curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash');
235
+ }
236
+ const args = [];
237
+ if (this.hermesProfile && this.hermesProfile !== 'default') {
238
+ args.push('-p', this.hermesProfile);
239
+ }
240
+ args.push(
241
+ 'chat',
242
+ '-q', prompt,
243
+ '-Q',
244
+ '--source', this.hermesSource,
245
+ '--max-turns', String(this.maxTurns),
246
+ );
247
+ if (resumeSessionId) args.push('--resume', resumeSessionId);
248
+ if (this.yolo) args.push('--yolo');
249
+ return args;
250
+ }
251
+
252
+ async _runHermes(prompt, channelName) {
253
+ const resumeId = this._channelSessions[channelName];
254
+ const args = this._buildHermesCmd(prompt, resumeId);
255
+ this._log(`Running hermes (profile=${this.hermesProfile}, channel=${channelName}, resume=${!!resumeId})`);
256
+
257
+ const env = { ...(this.agentEnv || process.env) };
258
+ const proc = spawn(this._hermesBin, args, {
259
+ env,
260
+ stdio: ['ignore', 'pipe', 'pipe'],
261
+ detached: !IS_WINDOWS,
262
+ });
263
+ this._channelProcesses[channelName] = proc;
264
+
265
+ let stdout = '';
266
+ let stderr = '';
267
+ proc.stdout.on('data', (d) => { stdout += d.toString('utf-8'); });
268
+ proc.stderr.on('data', (d) => { stderr += d.toString('utf-8'); });
269
+
270
+ const exitCode = await new Promise((resolve) => {
271
+ proc.on('exit', resolve);
272
+ proc.on('error', () => resolve(-1));
273
+ });
274
+ delete this._channelProcesses[channelName];
275
+
276
+ if (exitCode !== 0) {
277
+ if (resumeId) {
278
+ // Resume may have failed because the session was deleted — drop it and retry fresh
279
+ this._log(`Hermes resume failed (code=${exitCode}), retrying without resume`);
280
+ delete this._channelSessions[channelName];
281
+ this._saveSessions();
282
+ return this._runHermes(prompt, channelName);
283
+ }
284
+ const detail = (stderr || stdout).trim().slice(0, 600);
285
+ throw new Error(`hermes exited with code ${exitCode}: ${detail}`);
286
+ }
287
+
288
+ const { text, sessionId } = this._parseHermesOutput(stdout);
289
+ if (sessionId) {
290
+ this._channelSessions[channelName] = sessionId;
291
+ this._saveSessions();
292
+ }
293
+ return text;
294
+ }
295
+
296
+ async _stopProcess(proc) {
297
+ if (!proc || proc.exitCode !== null) return;
298
+ try {
299
+ if (IS_WINDOWS) {
300
+ try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
301
+ } else {
302
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch {
303
+ proc.kill('SIGTERM');
304
+ }
305
+ await new Promise((resolve) => {
306
+ const timeout = setTimeout(() => {
307
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch {
308
+ proc.kill('SIGKILL');
309
+ }
310
+ resolve();
311
+ }, 5000);
312
+ proc.on('exit', () => { clearTimeout(timeout); resolve(); });
313
+ });
314
+ }
315
+ } catch {}
316
+ }
317
+
318
+ async _onControlAction(action, _payload) {
319
+ if (action === 'stop') {
320
+ for (const [channel, proc] of Object.entries(this._channelProcesses)) {
321
+ await this._stopProcess(proc);
322
+ delete this._channelProcesses[channel];
323
+ try { await this.sendStatus(channel, 'Execution stopped by user'); } catch {}
324
+ }
325
+ }
326
+ }
327
+
328
+ // ------------------------------------------------------------------
329
+ // Message handler
330
+ // ------------------------------------------------------------------
331
+
332
+ async _handleMessage(msg) {
333
+ const content = (msg.content || '').trim();
334
+ if (!content) return;
335
+
336
+ const msgChannel = msg.sessionId || this.channelName;
337
+ const sender = msg.senderName || msg.senderType || 'user';
338
+ this._log(`Processing workspace message from ${sender} in ${msgChannel}`);
339
+
340
+ await this._autoTitleChannel(msgChannel, content);
341
+ await this.sendStatus(msgChannel, 'thinking...');
342
+
343
+ try {
344
+ const context = await this._buildContextPrefix(msgChannel);
345
+ const prompt = context ? `${context}\n\n---\n\nUser message:\n${content}` : content;
346
+ const responseText = await this._runHermes(prompt, msgChannel);
347
+
348
+ if (responseText) {
349
+ await this.sendResponse(msgChannel, responseText);
350
+ } else {
351
+ await this.sendResponse(msgChannel, 'No response generated. Please try again.');
352
+ }
353
+ } catch (e) {
354
+ this._log(`Hermes adapter error: ${e.message}`);
355
+ await this.sendError(msgChannel, `Error processing message: ${e.message}`);
356
+ }
357
+ }
358
+ }
359
+
360
+ module.exports = HermesAdapter;
@@ -11,6 +11,7 @@ const CodexAdapter = require('./codex');
11
11
  const OpenCodeAdapter = require('./opencode');
12
12
  const NanoClawAdapter = require('./nanoclaw');
13
13
  const CursorAdapter = require('./cursor');
14
+ const HermesAdapter = require('./hermes');
14
15
 
15
16
  const ADAPTER_MAP = {
16
17
  openclaw: OpenClawAdapter,
@@ -19,11 +20,12 @@ const ADAPTER_MAP = {
19
20
  opencode: OpenCodeAdapter,
20
21
  nanoclaw: NanoClawAdapter,
21
22
  cursor: CursorAdapter,
23
+ hermes: HermesAdapter,
22
24
  };
23
25
 
24
26
  /**
25
27
  * Create an adapter instance for the given agent type.
26
- * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor)
28
+ * @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes)
27
29
  * @param {object} opts - Adapter constructor options
28
30
  * @returns {BaseAdapter}
29
31
  */
@@ -43,6 +45,7 @@ module.exports = {
43
45
  OpenCodeAdapter,
44
46
  NanoClawAdapter,
45
47
  CursorAdapter,
48
+ HermesAdapter,
46
49
  createAdapter,
47
50
  ADAPTER_MAP,
48
51
  };
package/src/cli.js CHANGED
@@ -117,7 +117,7 @@ async function cmdStatus(connector) {
117
117
 
118
118
  async function cmdCreate(connector, flags, positional) {
119
119
  const name = positional[0];
120
- if (!name) { print('Usage: agn create <name> [--type <type>]'); return; }
120
+ if (!name) { print('Usage: agn create <name> [--type <type>] [--install]'); return; }
121
121
  const type = flags.type || 'openclaw';
122
122
  const role = flags.role || 'worker';
123
123
 
@@ -128,8 +128,12 @@ async function cmdCreate(connector, flags, positional) {
128
128
  // Signal daemon to pick up the new agent
129
129
  try { connector.sendDaemonCommand('reload'); } catch {}
130
130
 
131
- // Auto-install if not installed
132
131
  if (!connector.isInstalled(type)) {
132
+ if (!flags.install) {
133
+ print(`Runtime '${type}' is not installed. Run: agn install ${type}`);
134
+ return;
135
+ }
136
+
133
137
  print(`Installing ${type}...`);
134
138
  try {
135
139
  await connector.install(type);
@@ -513,6 +517,7 @@ Commands:
513
517
 
514
518
  Options:
515
519
  --config <dir> Config directory (default: ~/.openagents)
520
+ --install Install runtime during create
516
521
  `);
517
522
  }
518
523
 
package/src/config.js CHANGED
@@ -50,13 +50,14 @@ class Config {
50
50
  fs.writeFileSync(this.configFile, serializeYaml(config), 'utf-8');
51
51
  }
52
52
 
53
- addAgent({ name, type, role, path: agentPath }) {
53
+ addAgent({ name, type, role, path: agentPath, env }) {
54
54
  const config = this.load();
55
55
  if (config.agents.some((a) => a.name === name)) {
56
56
  throw new Error(`Agent '${name}' already exists`);
57
57
  }
58
58
  const entry = { name, type: type || 'openclaw', role: role || 'worker' };
59
59
  if (agentPath) entry.path = agentPath;
60
+ if (env && Object.keys(env).length > 0) entry.env = env;
60
61
  config.agents.push(entry);
61
62
  this.save(config);
62
63
  return entry;
@@ -80,6 +81,27 @@ class Config {
80
81
  return agent;
81
82
  }
82
83
 
84
+ updateAgentEnv(name, env) {
85
+ const config = this.load();
86
+ const agent = config.agents.find((a) => a.name === name);
87
+ if (!agent) throw new Error(`Agent '${name}' not found`);
88
+
89
+ const merged = { ...(agent.env || {}), ...(env || {}) };
90
+ const cleaned = {};
91
+ for (const [key, value] of Object.entries(merged)) {
92
+ if (value !== null && value !== undefined && value !== '') cleaned[key] = value;
93
+ }
94
+
95
+ if (Object.keys(cleaned).length > 0) {
96
+ agent.env = cleaned;
97
+ } else {
98
+ delete agent.env;
99
+ }
100
+
101
+ this.save(config);
102
+ return agent.env || {};
103
+ }
104
+
83
105
  setAgentNetwork(agentName, networkSlug) {
84
106
  const config = this.load();
85
107
  const agent = config.agents.find((a) => a.name === agentName);
@@ -191,6 +213,43 @@ class Config {
191
213
  return { lines: [], size: 0 };
192
214
  }
193
215
  }
216
+
217
+ /**
218
+ * Remove log entries whose leading timestamp falls within [start, end].
219
+ * Lines without a parseable timestamp are preserved to avoid deleting
220
+ * stack traces or continuation lines incorrectly.
221
+ * @param {Object} opts - { start, end }
222
+ * @param {string|number|Date} opts.start
223
+ * @param {string|number|Date} opts.end
224
+ * @returns {{ removed: number, remaining: number }}
225
+ */
226
+ clearLogsInRange(opts = {}) {
227
+ const start = normalizeTimeValue(opts.start);
228
+ const end = normalizeTimeValue(opts.end);
229
+
230
+ if (!start || !end) {
231
+ throw new Error('Start time and end time are required');
232
+ }
233
+ if (start.getTime() > end.getTime()) {
234
+ throw new Error('Start time must be before end time');
235
+ }
236
+ if (!fs.existsSync(this.logFile)) {
237
+ return { removed: 0, remaining: 0 };
238
+ }
239
+
240
+ const content = fs.readFileSync(this.logFile, 'utf-8');
241
+ const hasTrailingNewline = content.endsWith('\n');
242
+ const allLines = content.split('\n');
243
+ if (hasTrailingNewline) allLines.pop();
244
+ const { keptLines, removed } = filterLogsByTimeRange(allLines, start, end);
245
+
246
+ const nextContent = keptLines.join('\n') + (hasTrailingNewline && keptLines.length > 0 ? '\n' : '');
247
+ const tempFile = `${this.logFile}.tmp`;
248
+ fs.writeFileSync(tempFile, nextContent, 'utf-8');
249
+ fs.renameSync(tempFile, this.logFile);
250
+
251
+ return { removed, remaining: keptLines.length };
252
+ }
194
253
  }
195
254
 
196
255
  // -- YAML parser (compatible with Python SDK's daemon.yaml format) --
@@ -253,6 +312,115 @@ function parseYaml(text) {
253
312
  return result;
254
313
  }
255
314
 
315
+ function normalizeTimeValue(value) {
316
+ if (value instanceof Date) {
317
+ return Number.isNaN(value.getTime()) ? null : value;
318
+ }
319
+ if (typeof value === 'number') {
320
+ const date = new Date(value);
321
+ return Number.isNaN(date.getTime()) ? null : date;
322
+ }
323
+ if (typeof value === 'string' && value.trim()) {
324
+ const date = new Date(value);
325
+ return Number.isNaN(date.getTime()) ? null : date;
326
+ }
327
+ return null;
328
+ }
329
+
330
+ function filterLogsByTimeRange(lines, start, end) {
331
+ const headerTimes = resolveLogHeaderTimestamps(lines, end);
332
+ let activeRemove = false;
333
+ let removed = 0;
334
+ const keptLines = [];
335
+
336
+ for (let index = 0; index < lines.length; index += 1) {
337
+ const headerTime = headerTimes[index];
338
+ if (headerTime) {
339
+ const time = headerTime.getTime();
340
+ activeRemove = time >= start.getTime() && time <= end.getTime();
341
+ }
342
+
343
+ if (activeRemove) {
344
+ removed += 1;
345
+ } else {
346
+ keptLines.push(lines[index]);
347
+ }
348
+ }
349
+
350
+ return { keptLines, removed };
351
+ }
352
+
353
+ function resolveLogHeaderTimestamps(lines, referenceTime) {
354
+ const resolved = new Array(lines.length).fill(null);
355
+ let currentDay = startOfLocalDay(referenceTime);
356
+ let lastClockSeconds = null;
357
+
358
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
359
+ const token = parseLogTimestampToken(lines[index]);
360
+ if (!token) continue;
361
+
362
+ if (token.kind === 'iso') {
363
+ resolved[index] = token.date;
364
+ currentDay = startOfLocalDay(token.date);
365
+ lastClockSeconds = (
366
+ token.date.getHours() * 3600 +
367
+ token.date.getMinutes() * 60 +
368
+ token.date.getSeconds()
369
+ );
370
+ continue;
371
+ }
372
+
373
+ if (lastClockSeconds !== null && token.seconds > lastClockSeconds) {
374
+ currentDay = addLocalDays(currentDay, -1);
375
+ }
376
+
377
+ resolved[index] = withLocalClock(currentDay, token.seconds);
378
+ lastClockSeconds = token.seconds;
379
+ }
380
+
381
+ return resolved;
382
+ }
383
+
384
+ function parseLogTimestampToken(line) {
385
+ if (!line) return null;
386
+
387
+ const isoMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}))/);
388
+ if (isoMatch) {
389
+ const date = new Date(isoMatch[1]);
390
+ if (!Number.isNaN(date.getTime())) {
391
+ return { kind: 'iso', date };
392
+ }
393
+ }
394
+
395
+ const clockMatch = line.match(/^\[(\d{2}):(\d{2}):(\d{2})\]/);
396
+ if (clockMatch) {
397
+ return {
398
+ kind: 'clock',
399
+ seconds:
400
+ Number(clockMatch[1]) * 3600 +
401
+ Number(clockMatch[2]) * 60 +
402
+ Number(clockMatch[3]),
403
+ };
404
+ }
405
+
406
+ return null;
407
+ }
408
+
409
+ function startOfLocalDay(date) {
410
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
411
+ }
412
+
413
+ function addLocalDays(date, days) {
414
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
415
+ }
416
+
417
+ function withLocalClock(day, seconds) {
418
+ const hours = Math.floor(seconds / 3600);
419
+ const minutes = Math.floor((seconds % 3600) / 60);
420
+ const secs = seconds % 60;
421
+ return new Date(day.getFullYear(), day.getMonth(), day.getDate(), hours, minutes, secs);
422
+ }
423
+
256
424
  function parseYamlValue(val) {
257
425
  if (val === '' || val === 'null' || val === '~') return null;
258
426
  if (val === 'true') return true;
package/src/daemon.js CHANGED
@@ -70,7 +70,7 @@ class Daemon {
70
70
  this._writeStatus();
71
71
  this._cachedAgentNames = new Set(agents.map(a => a.name));
72
72
  this._cachedAgentConfigs = {};
73
- for (const a of agents) this._cachedAgentConfigs[a.name] = a.network || '';
73
+ for (const a of agents) this._cachedAgentConfigs[a.name] = this._agentConfigFingerprint(a);
74
74
  this._log(`Daemon started with ${agents.length} agent(s)`);
75
75
 
76
76
  // Block until shutdown
@@ -553,11 +553,19 @@ class Daemon {
553
553
  _buildAgentEnv(agentCfg) {
554
554
  const type = agentCfg.type || 'openclaw';
555
555
  const saved = this.envManager.load(type);
556
- const resolved = this.envManager.resolve(type, saved, this.registry);
557
- const merged = { ...saved, ...resolved, ...(agentCfg.env || {}) };
556
+ const mergedSaved = { ...saved, ...(agentCfg.env || {}) };
557
+ const resolved = this.envManager.resolve(type, mergedSaved, this.registry);
558
+ const merged = { ...mergedSaved, ...resolved };
558
559
  return { ...process.env, ...merged };
559
560
  }
560
561
 
562
+ _agentConfigFingerprint(agentCfg) {
563
+ return JSON.stringify({
564
+ network: agentCfg.network || '',
565
+ env: agentCfg.env || {},
566
+ });
567
+ }
568
+
561
569
  // ---------------------------------------------------------------------------
562
570
  // Internal — agent kill
563
571
  // ---------------------------------------------------------------------------
@@ -682,7 +690,7 @@ class Daemon {
682
690
  const newAgents = this.config.getAgents();
683
691
  const newNames = new Set(newAgents.map(a => a.name));
684
692
  const newConfigs = {};
685
- for (const a of newAgents) newConfigs[a.name] = a.network || '';
693
+ for (const a of newAgents) newConfigs[a.name] = this._agentConfigFingerprint(a);
686
694
 
687
695
  // Stop removed agents
688
696
  for (const name of oldNames) {
@@ -698,13 +706,13 @@ class Daemon {
698
706
  await this._ensureAdapterCleared(agent.name);
699
707
  this._launchAgent(agent);
700
708
  this._log(`Reload: started new agent '${agent.name}'`);
701
- } else if ((oldConfigs[agent.name] || '') !== (agent.network || '')) {
702
- // Network config changed — restart agent
709
+ } else if ((oldConfigs[agent.name] || '') !== newConfigs[agent.name]) {
710
+ // Network or env config changed — restart agent
703
711
  await this.stopAgent(agent.name);
704
712
  this._stoppedAgents.delete(agent.name);
705
713
  await this._ensureAdapterCleared(agent.name);
706
714
  this._launchAgent(agent);
707
- this._log(`Reload: restarted '${agent.name}' (network changed)`);
715
+ this._log(`Reload: restarted '${agent.name}' (config changed)`);
708
716
  }
709
717
  }
710
718
 
package/src/index.js CHANGED
@@ -74,22 +74,24 @@ class AgentConnector {
74
74
  const agents = this.config.getAgents();
75
75
  const networks = this.config.getNetworks();
76
76
  return agents.map((a) => {
77
- const agentEnv = this.env.load(a.type);
77
+ const type = a.type || 'openclaw';
78
+ const typeEnv = this.env.load(type);
78
79
  const network = networks.find((n) => n.slug === a.network || n.id === a.network);
79
80
  return {
80
81
  name: a.name,
81
- type: a.type || 'openclaw',
82
+ type,
82
83
  role: a.role || 'worker',
83
84
  network: a.network || null,
84
85
  networkName: network ? (network.name || network.slug) : null,
85
86
  path: a.path || null,
86
- env: { ...agentEnv, ...(a.env || {}) },
87
+ env: { ...typeEnv, ...(a.env || {}) },
88
+ instanceEnv: { ...(a.env || {}) },
87
89
  };
88
90
  });
89
91
  }
90
92
 
91
- addAgent({ name, type, role, path }) {
92
- this.config.addAgent({ name, type: type || 'openclaw', role: role || 'worker', path });
93
+ addAgent({ name, type, role, path, env }) {
94
+ this.config.addAgent({ name, type: type || 'openclaw', role: role || 'worker', path, env });
93
95
  return { success: true };
94
96
  }
95
97
 
@@ -104,6 +106,12 @@ class AgentConnector {
104
106
  return this.env.load(agentType);
105
107
  }
106
108
 
109
+ getAgentInstanceEnv(agentName) {
110
+ const agent = this.config.getAgent(agentName);
111
+ if (!agent) throw new Error(`Agent '${agentName}' not found`);
112
+ return { ...(agent.env || {}) };
113
+ }
114
+
107
115
  saveAgentEnv(agentType, env) {
108
116
  this.env.save(agentType, env);
109
117
  // Configure native auth for agents that need it (e.g. OpenClaw auth-profiles.json)
@@ -117,6 +125,24 @@ class AgentConnector {
117
125
  return { success: true };
118
126
  }
119
127
 
128
+ saveAgentInstanceEnv(agentName, env) {
129
+ const agent = this.config.getAgent(agentName);
130
+ if (!agent) throw new Error(`Agent '${agentName}' not found`);
131
+ const saved = this.config.updateAgentEnv(agentName, env);
132
+
133
+ // Preserve native auth side effects for agents that need them while
134
+ // keeping the model choice scoped to this individual agent.
135
+ try {
136
+ if ((agent.type || 'openclaw') === 'openclaw') {
137
+ const OpenClawAdapter = require('./adapters/openclaw');
138
+ const typeEnv = this.env.load(agent.type || 'openclaw');
139
+ OpenClawAdapter.configureNativeAuth({ ...typeEnv, ...saved });
140
+ }
141
+ } catch {}
142
+
143
+ return { success: true };
144
+ }
145
+
120
146
  resolveAgentEnv(agentType, saved) {
121
147
  return this.env.resolve(agentType, saved, this.registry);
122
148
  }
@@ -199,6 +225,14 @@ class AgentConnector {
199
225
  return this.config.getLogs(agentName, lines);
200
226
  }
201
227
 
228
+ tailLogs(opts = {}) {
229
+ return this.config.tailLogs(opts);
230
+ }
231
+
232
+ clearLogsInRange(opts = {}) {
233
+ return this.config.clearLogsInRange(opts);
234
+ }
235
+
202
236
  // -- Workspace API --
203
237
 
204
238
  async createWorkspace(opts) {
@@ -179,6 +179,29 @@ class WorkspaceClient {
179
179
  return events.map((e) => this._eventToMessage(e));
180
180
  }
181
181
 
182
+ /**
183
+ * Fetch the latest workspace.message.posted event id (head cursor).
184
+ * Used by adapters to skip past existing events on join in O(1) instead
185
+ * of paginating from the start. Returns null if the workspace is empty
186
+ * or the request fails.
187
+ */
188
+ async getHeadEventId(workspaceId, token) {
189
+ try {
190
+ const params = new URLSearchParams({
191
+ network: workspaceId,
192
+ type: 'workspace.message.posted',
193
+ sort: 'desc',
194
+ limit: '1',
195
+ });
196
+ const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
197
+ const result = data.data || data;
198
+ const events = (result && result.events) || [];
199
+ return events.length > 0 ? (events[0].id || null) : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
182
205
  /**
183
206
  * Poll for pending messages targeted at an agent via GET /v1/events.
184
207
  * Returns { messages, cursor } where cursor is the last event ID.