@openagents-org/agent-launcher 0.2.145 → 0.2.147

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.145",
3
+ "version": "0.2.147",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -70,7 +70,6 @@ class BaseAdapter {
70
70
  this._stopRequested = false;
71
71
  this.client = new WorkspaceClient(this.endpoint);
72
72
  this._lastEventId = null;
73
- this._lastToolResultId = null;
74
73
  this._running = false;
75
74
  this._sessionId = null; // issued by server on /v1/join; used to prove liveness
76
75
  this._processedIds = new Set();
@@ -653,31 +652,6 @@ class BaseAdapter {
653
652
  idleCount++;
654
653
  }
655
654
 
656
- // Sidecar poll: A2UI tool_result events. These are the user's response
657
- // to a UI spec this agent (or any agent in the network) emitted. We
658
- // surface each one as a synthetic user message so the LLM sees it as
659
- // the next turn and can react. Failures here don't break the main
660
- // message poll.
661
- try {
662
- const toolResult = await this.client.pollToolResults(
663
- this.workspaceId, this.token,
664
- { after: this._lastToolResultId }
665
- );
666
- if (toolResult.cursor) this._lastToolResultId = toolResult.cursor;
667
- for (const event of toolResult.events || []) {
668
- const msgId = event.id;
669
- if (msgId && this._processedIds.has(msgId)) continue;
670
- if (msgId) this._processedIds.add(msgId);
671
- const synth = synthesizeToolResultMessage(event);
672
- if (synth) await this._dispatchMessage(synth);
673
- }
674
- } catch (e) {
675
- // Non-fatal — log once per poll if it fails
676
- if (pollCount <= 3 || pollCount % 20 === 0) {
677
- this._log(`tool_result poll #${pollCount} failed: ${e.message}`);
678
- }
679
- }
680
-
681
655
  // Adaptive polling with warm plateau:
682
656
  // Active (messages incoming): 2s
683
657
  // Warm (≤5 min since last msg): 5s
@@ -811,15 +785,10 @@ class BaseAdapter {
811
785
  }
812
786
 
813
787
  async sendThinking(channel, content) {
814
- // Strip ```a2ui blocks if they leak into Claude's intermediate thinking
815
- // trace — the real spec gets emitted via sendResponse with proper
816
- // payload.spec extraction, so showing the raw block here is just noise
817
- // (and a duplicate). If stripping leaves the thinking message empty,
818
- // skip it entirely.
819
- const { cleanContent } = extractA2UISpec(content);
820
- if (!cleanContent || !cleanContent.trim()) return;
788
+ // Skip empty thinking traces entirely.
789
+ if (!content || !content.trim()) return;
821
790
  try {
822
- await this.client.sendMessage(this.workspaceId, channel, this.token, cleanContent, {
791
+ await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
823
792
  senderType: 'agent',
824
793
  senderName: this.agentName,
825
794
  messageType: 'thinking',
@@ -832,14 +801,11 @@ class BaseAdapter {
832
801
  }
833
802
 
834
803
  async sendResponse(channel, content) {
835
- const { cleanContent, spec, specToolCallId } = extractA2UISpec(content);
836
804
  try {
837
- await this.client.sendMessage(this.workspaceId, channel, this.token, cleanContent, {
805
+ await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
838
806
  senderType: 'agent',
839
807
  senderName: this.agentName,
840
808
  sessionId: this._sessionId,
841
- spec,
842
- specToolCallId,
843
809
  });
844
810
  } catch (e) {
845
811
  if (e instanceof SessionRevokedError) {
@@ -966,74 +932,4 @@ class BaseAdapter {
966
932
  }
967
933
  }
968
934
 
969
- // ------------------------------------------------------------------
970
- // A2UI helpers
971
- // ------------------------------------------------------------------
972
-
973
- /**
974
- * Pull the first ```a2ui ... ``` fenced block out of LLM-produced content.
975
- * Returns the content with the block stripped, the parsed spec, and a
976
- * tool-call id derived from `spec.tool_call_id` (if present) or a new one.
977
- * If no block is present or parsing fails, returns the content unchanged
978
- * with null spec — the message still goes out as plain markdown.
979
- */
980
- /**
981
- * Convert a workspace.tool_result event into a synthetic user-message
982
- * shape that the agent's _handleMessage can dispatch. The LLM sees this
983
- * as the next user turn — the content is a short, machine-readable line
984
- * the LLM can parse without ambiguity. The original spec it emitted is
985
- * already in the LLM's conversation history; the tool_call_id lets the
986
- * LLM correlate this back.
987
- */
988
- function synthesizeToolResultMessage(event) {
989
- if (!event || !event.payload) return null;
990
- const p = event.payload;
991
- const actionId = p.action_id || '';
992
- const toolCallId = p.tool_call_id || '';
993
- let valueStr = '';
994
- if (p.value !== undefined && p.value !== null) {
995
- try { valueStr = JSON.stringify(p.value); } catch (_) { valueStr = String(p.value); }
996
- }
997
- const lines = [
998
- '[ui_action]',
999
- `action=${actionId}`,
1000
- toolCallId ? `tool_call_id=${toolCallId}` : null,
1001
- valueStr ? `value=${valueStr}` : null,
1002
- ].filter(Boolean);
1003
- const content = lines.join(' ');
1004
- const target = event.target || '';
1005
- return {
1006
- messageId: event.id || '',
1007
- sessionId: target.startsWith('channel/') ? target.replace('channel/', '') : target,
1008
- senderType: 'human',
1009
- senderName: 'user',
1010
- content,
1011
- mentions: [],
1012
- messageType: 'chat',
1013
- metadata: event.metadata || {},
1014
- };
1015
- }
1016
-
1017
- function extractA2UISpec(content) {
1018
- if (!content || typeof content !== 'string') {
1019
- return { cleanContent: content, spec: null, specToolCallId: null };
1020
- }
1021
- const match = content.match(/```a2ui\s*\n([\s\S]*?)\n```/);
1022
- if (!match) return { cleanContent: content, spec: null, specToolCallId: null };
1023
-
1024
- let spec;
1025
- try {
1026
- spec = JSON.parse(match[1]);
1027
- } catch (_) {
1028
- return { cleanContent: content, spec: null, specToolCallId: null };
1029
- }
1030
-
1031
- const specToolCallId = (spec && spec.tool_call_id) || `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1032
- if (spec && spec.tool_call_id) delete spec.tool_call_id;
1033
-
1034
- const cleanContent = content.replace(match[0], '').trim();
1035
- return { cleanContent, spec, specToolCallId };
1036
- }
1037
-
1038
935
  module.exports = BaseAdapter;
1039
- module.exports.extractA2UISpec = extractA2UISpec;
@@ -182,7 +182,41 @@ class GeminiAdapter extends BaseAdapter {
182
182
  return null;
183
183
  }
184
184
 
185
+ // The Gemini CLI refuses to run non-interactively unless an auth method is
186
+ // explicitly selected in ~/.gemini/settings.json ("Invalid auth method
187
+ // selected"), even when GEMINI_API_KEY is present. When the user configured an
188
+ // API key, select API-key auth (both the legacy flat and current nested
189
+ // schema) and disable folder-trust so `-y` isn't downgraded. We never override
190
+ // an existing non-api-key selection (e.g. a real OAuth sign-in).
191
+ _ensureGeminiAuth() {
192
+ if (this._geminiAuthReady) return;
193
+ this._geminiAuthReady = true;
194
+ try {
195
+ const env = this.agentEnv || process.env;
196
+ const apiKey = (env.GEMINI_API_KEY || env.GOOGLE_API_KEY || '').trim();
197
+ if (!apiKey) return; // OAuth / other — leave the user's setup alone.
198
+ const dir = path.join(os.homedir(), '.gemini');
199
+ fs.mkdirSync(dir, { recursive: true });
200
+ const file = path.join(dir, 'settings.json');
201
+ let settings = {};
202
+ try { settings = JSON.parse(fs.readFileSync(file, 'utf-8')) || {}; } catch {}
203
+ const current =
204
+ settings.selectedAuthType ||
205
+ (settings.security && settings.security.auth && settings.security.auth.selectedType);
206
+ if (current && current !== 'gemini-api-key') return; // respect explicit OAuth/Vertex.
207
+ settings.selectedAuthType = 'gemini-api-key';
208
+ settings.security = settings.security || {};
209
+ settings.security.auth = { ...(settings.security.auth || {}), selectedType: 'gemini-api-key' };
210
+ settings.security.folderTrust = { ...(settings.security.folderTrust || {}), enabled: false };
211
+ fs.writeFileSync(file, JSON.stringify(settings, null, 2));
212
+ this._log('Configured Gemini CLI for API-key auth (~/.gemini/settings.json)');
213
+ } catch (e) {
214
+ this._log(`gemini auth setup skipped: ${e.message}`);
215
+ }
216
+ }
217
+
185
218
  _buildGeminiCmd(prompt, channelName, { skipResume = false } = {}) {
219
+ this._ensureGeminiAuth();
186
220
  const geminiBin = this._findGeminiBinary();
187
221
  if (!geminiBin) {
188
222
  throw new Error('gemini CLI not found. Install with: npm install -g @google/gemini-cli');
@@ -462,7 +462,6 @@ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = '
462
462
  );
463
463
  parts.push(buildBrowserDirective(browserEnabled));
464
464
  parts.push(buildCollaborationPrompt());
465
- parts.push(buildA2UIPrompt());
466
465
 
467
466
  if (mode === 'plan') {
468
467
  parts.push(
@@ -476,83 +475,6 @@ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = '
476
475
  return parts.join('\n');
477
476
  }
478
477
 
479
- /**
480
- * Teach the LLM how to emit interactive UI alongside its text response.
481
- * The frontend (OpenAgents Go) renders any A2UI-shaped JSON spec inline
482
- * in the chat bubble; the agent-connector strips the fenced block before
483
- * posting and ferries the spec via payload.spec.
484
- */
485
- function buildA2UIPrompt() {
486
- return (
487
- '\n## Rendering interactive UI\n' +
488
- 'When you want the user to interact with structured UI (a button choice, ' +
489
- 'a form, a chart, a table, a confirmation dialog) instead of just reading ' +
490
- 'text, emit a fenced code block tagged `a2ui` containing a JSON spec. The ' +
491
- "frontend renders it inline below any markdown narration you write.\n\n" +
492
- "The spec is a tree of `{ type, props, children?, action? }` nodes. " +
493
- 'Supported component types include `Stack`, `Heading`, `Text`, `Image`, ' +
494
- '`Icon`, `Button`, `ChoiceList`, `ConfirmDialog`, `AmountInput`, `Card`, ' +
495
- '`Divider`, `Spacer`, `Alert`, `LineChart`, `PieChart`, `AssetPrice`, ' +
496
- '`BalanceCard`, `TransactionList`, `TransactionRow`. Unknown types render ' +
497
- 'as a placeholder chip, so feel free to compose freely.\n\n' +
498
- 'Interactive components attach an `action` object with a `name` field you ' +
499
- 'choose. Example:\n\n' +
500
- '```a2ui\n' +
501
- '{\n' +
502
- ' "type": "Stack",\n' +
503
- ' "props": { "direction": "vertical", "spacing": 12 },\n' +
504
- ' "children": [\n' +
505
- ' { "type": "Heading", "props": { "text": "Pick a time", "level": 2 } },\n' +
506
- ' { "type": "Button", "props": { "label": "Morning", "action": { "name": "pick_morning" } } },\n' +
507
- ' { "type": "Button", "props": { "label": "Evening", "action": { "name": "pick_evening" } } }\n' +
508
- ' ]\n' +
509
- '}\n' +
510
- '```\n\n' +
511
- '### Exact prop names (these are checked verbatim — guessing will render "No data")\n\n' +
512
- 'Layout / content:\n' +
513
- '- `Stack` — props: `direction` ("vertical"|"horizontal"), `spacing`, `alignment`\n' +
514
- '- `Card` — props: `title`, `padding`, `cornerRadius`\n' +
515
- '- `Divider`, `Spacer` — props: `size`, `orientation`\n' +
516
- '- `Heading` — props: `text`, `level` (1–4)\n' +
517
- '- `Text` — props: `content` **(NOT `text`)**, `style`, `weight`, `color`\n' +
518
- '- `Image` — props: `url`, `name`, `contentMode`, `width`, `height`\n' +
519
- '- `Icon` — props: `name`, `size`, `color`\n\n' +
520
- 'Interactive:\n' +
521
- '- `Button` — props: `label`, `style` ("primary"|"secondary"|"destructive"), `icon`, `disabled`, `action: {name, params?}`\n' +
522
- '- `ConfirmDialog` — props: `title`, `message`, `confirmLabel`, `cancelLabel`, `triggerLabel`, `action`\n' +
523
- '- `ChoiceList` — props: `question`, `options: [{id, label}]`, `action`\n' +
524
- '- `AmountInput` — props: `label`, `placeholder`, `currency`, `action`\n' +
525
- '- `input` (lowercase) — props: `inputType` ("text"|"choice"|"number"|"date"), `id`, `label`, `placeholder`, `options`\n\n' +
526
- 'Feedback:\n' +
527
- '- `Alert` — props: `title`, `message`, `severity` ("info"|"success"|"warning"|"error"), `dismissible`, `action`\n\n' +
528
- 'Charts (Swift Charts under the hood — needs **non-empty** data with the right key):\n' +
529
- '- `LineChart` — props: `title`, `color`, `points: [{x, y}]` **(NOT `data`)**\n' +
530
- '- `PieChart` — props: `title`, `segments: [{label, value, color?}]` **(NOT `slices`)**, `showLegend`\n' +
531
- '- `chart` (lowercase) — simple sparkline/bar: props: `style` ("sparkline"|"bar"), `data: [number, ...]`, `labels`, `color`\n\n' +
532
- 'Data display:\n' +
533
- '- `metric` (lowercase) — props: `label`, `value`, `caption`, `captionColor`, `icon`\n' +
534
- '- `list` (lowercase) — props: `items: [{title, subtitle, trailing, icon}]`, `maxVisible`, `expandLabel`\n' +
535
- '- `table` (lowercase) — **key-value rows only, not multi-column**: props: `rows: [{label, value}]`, `maxVisible`\n\n' +
536
- 'There is no multi-column data table in the catalog. For tabular data with ' +
537
- 'columns you have two choices: (a) use `list` with title/subtitle/trailing ' +
538
- 'mapped to your columns, or (b) compose a Stack of horizontal Stacks of Text ' +
539
- 'manually. Pick whichever reads better.\n\n' +
540
- 'If your data is empty or you can\'t fit it into the available primitives, ' +
541
- 'fall back to plain markdown — don\'t emit a spec just to wrap text in a card.\n\n' +
542
- 'When the user interacts with a rendered component, you will receive a ' +
543
- "user message shaped like `[ui_action] action=pick_morning tool_call_id=... value=...`. " +
544
- "Use the `action` name (which you chose) to decide what to do next; the " +
545
- "spec you emitted earlier is in your conversation history for context.\n\n" +
546
- 'Use a spec when:\n' +
547
- '- The user needs to make a discrete choice (offer buttons rather than ' +
548
- 'asking them to type a free-form answer).\n' +
549
- '- Structured data is easier to scan as a chart or table than as prose.\n' +
550
- '- You need explicit confirmation before a destructive action.\n' +
551
- "- A form would gather several fields faster than back-and-forth chat.\n\n" +
552
- "Don't emit a spec for ordinary prose answers — narration alone is fine.\n"
553
- );
554
- }
555
-
556
478
  /**
557
479
  * Build the full system prompt for OpenClaw/non-MCP agents.
558
480
  */
@@ -561,7 +483,6 @@ function buildOpenclawSystemPrompt({ agentName, workspaceId, channelName, endpoi
561
483
  parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode));
562
484
  parts.push(buildBrowserDirective(browserEnabled));
563
485
  parts.push(buildCollaborationPrompt());
564
- parts.push(buildA2UIPrompt());
565
486
  parts.push(buildModePrompt(mode));
566
487
  parts.push(buildApiSkillsPrompt({
567
488
  endpoint, workspaceId, token, agentName, channelName, disabledModules, mode,
package/src/cli.js CHANGED
@@ -103,6 +103,19 @@ async function cmdDown(connector) {
103
103
  }
104
104
  }
105
105
 
106
+ async function cmdRestart(connector, flags) {
107
+ // Stop then start. stopDaemon() already SIGTERM/SIGKILLs and waits for the
108
+ // process to die, but poll getDaemonPid() (which checks liveness) until the
109
+ // pid clears so the singleton guard in cmdUp can't trip on a not-yet-gone
110
+ // daemon and refuse to start.
111
+ const stopped = connector.stopDaemon();
112
+ print(stopped ? 'Daemon stopped' : 'Daemon was not running');
113
+ for (let i = 0; i < 25 && connector.getDaemonPid(); i++) {
114
+ await new Promise((resolve) => setTimeout(resolve, 200));
115
+ }
116
+ await cmdUp(connector, flags);
117
+ }
118
+
106
119
  async function cmdStatus(connector) {
107
120
  const pid = connector.getDaemonPid();
108
121
  if (!pid) {
@@ -134,6 +147,18 @@ async function cmdCreate(connector, flags, positional) {
134
147
  const type = flags.type || 'openclaw';
135
148
  const role = flags.role || 'worker';
136
149
 
150
+ // Validate the runtime type BEFORE creating any local entry. A typo'd or
151
+ // otherwise unknown type (e.g. "calude") must not leave a broken agent in
152
+ // config — an uninstalled-but-valid type is fine and handled further down.
153
+ if (!connector.registry.getEntry(type)) {
154
+ const known = connector.registry.getCatalogSync().map((e) => e.name).sort();
155
+ print(`Error: unknown agent type '${type}'.`);
156
+ print(`Supported types: ${known.join(', ')}`);
157
+ print('Run `agn search` to browse available agent types.');
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+
137
162
  try {
138
163
  connector.addAgent({ name, type, role, path: flags.path || process.cwd() });
139
164
 
@@ -708,6 +733,7 @@ async function cmdHelp() {
708
733
  Commands:
709
734
  up [--foreground] Start daemon (background by default)
710
735
  down Stop daemon
736
+ restart [--foreground] Restart daemon (down + up)
711
737
  status Show agent status
712
738
  list List configured agents
713
739
  create <name> [--type T] Create a new agent
@@ -792,6 +818,7 @@ async function main() {
792
818
  tui: () => { const { run } = require('./tui'); run(); },
793
819
  up: () => cmdUp(connector, flags),
794
820
  down: () => cmdDown(connector),
821
+ restart: () => cmdRestart(connector, flags),
795
822
  status: () => cmdStatus(connector),
796
823
  list: () => cmdList(connector),
797
824
  create: () => cmdCreate(connector, flags, positional),
package/src/daemon.js CHANGED
@@ -235,15 +235,17 @@ class Daemon {
235
235
  // Refuse to start if an existing daemon is already running.
236
236
  // Without this check, repeated `agn up` invocations would spawn
237
237
  // multiple daemons that each process the same message → duplicate
238
- // bot replies.
239
- const existingPid = Daemon._readPid(pidFile);
240
- if (existingPid && Daemon._isAlive(existingPid)) {
238
+ // bot replies. Check BOTH the pid file and the status file (a live daemon
239
+ // rewrites the latter every 5s with its own pid): a clobbered/stale pid
240
+ // file must not let a duplicate slip past this guard.
241
+ const existingPid = Daemon.runningDaemonPid(configDir);
242
+ if (existingPid) {
241
243
  console.error(`Daemon already running (PID ${existingPid}).`);
242
244
  console.error(`Run 'agn down' first, or 'agn status' to check.`);
243
245
  process.exit(1);
244
246
  }
245
247
  // Stale pid file — clean up before spawning fresh
246
- if (existingPid) {
248
+ if (Daemon._readPid(pidFile)) {
247
249
  try { fs.unlinkSync(pidFile); } catch {}
248
250
  }
249
251
 
@@ -287,41 +289,53 @@ class Daemon {
287
289
  const pidFile = path.join(configDir, 'daemon.pid');
288
290
  const statusFile = path.join(configDir, 'daemon.status.json');
289
291
 
290
- const pid = Daemon._readPid(pidFile);
291
- if (!pid) return false;
292
-
293
- try {
294
- if (IS_WINDOWS) {
295
- execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
296
- } else {
297
- process.kill(pid, 'SIGTERM');
298
- }
299
- } catch {}
300
-
301
- // Always clean up PID and status files after kill attempt
302
- try { fs.unlinkSync(pidFile); } catch {}
303
- try { fs.unlinkSync(statusFile); } catch {}
304
-
305
- // Wait briefly for process to die
306
- for (let i = 0; i < 5; i++) {
307
- if (!Daemon._isAlive(pid)) return true;
308
- execSync(IS_WINDOWS ? 'ping -n 2 127.0.0.1 >nul' : 'sleep 0.5', {
309
- stdio: 'ignore', timeout: 5000,
310
- });
292
+ // Resolve the REAL running daemon(s) from BOTH the pid file and the status
293
+ // file. A live daemon rewrites the status file every 5s with its own pid,
294
+ // so when the pid file is stale/clobbered the status file is the only
295
+ // record of the actual process. The old code trusted only the pid file:
296
+ // `agn down` would signal a dead pid, delete the files, report "Daemon
297
+ // stopped", and leave the real daemon orphaned — which then blocked every
298
+ // subsequent `agn up` ("already running") and kept its agents wedged.
299
+ const pids = Daemon._liveDaemonPids(configDir);
300
+
301
+ // SIGTERM every distinct live pid.
302
+ for (const pid of pids) {
303
+ try {
304
+ if (IS_WINDOWS) {
305
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
306
+ } else {
307
+ process.kill(pid, 'SIGTERM');
308
+ }
309
+ } catch {}
311
310
  }
312
311
 
313
- // Force kill
314
- try {
315
- if (IS_WINDOWS) {
316
- execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
317
- } else {
318
- process.kill(pid, 'SIGKILL');
312
+ // Wait briefly, then SIGKILL any survivor that ignored SIGTERM (this is
313
+ // what today's zombie required — a foreground daemon that didn't exit on
314
+ // SIGTERM).
315
+ for (const pid of pids) {
316
+ let alive = Daemon._isAlive(pid);
317
+ for (let i = 0; alive && i < 5; i++) {
318
+ execSync(IS_WINDOWS ? 'ping -n 2 127.0.0.1 >nul' : 'sleep 0.5', {
319
+ stdio: 'ignore', timeout: 5000,
320
+ });
321
+ alive = Daemon._isAlive(pid);
319
322
  }
320
- } catch {}
323
+ if (alive) {
324
+ try {
325
+ if (IS_WINDOWS) {
326
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
327
+ } else {
328
+ process.kill(pid, 'SIGKILL');
329
+ }
330
+ } catch {}
331
+ }
332
+ }
321
333
 
334
+ // Always clear BOTH sources of truth so a fresh `agn up` starts clean.
322
335
  try { fs.unlinkSync(pidFile); } catch {}
323
336
  try { fs.unlinkSync(statusFile); } catch {}
324
- return true;
337
+
338
+ return pids.length > 0;
325
339
  }
326
340
 
327
341
  /**
@@ -331,14 +345,18 @@ class Daemon {
331
345
  const pidFile = path.join(configDir, 'daemon.pid');
332
346
  const statusFile = path.join(configDir, 'daemon.status.json');
333
347
  const pid = Daemon._readPid(pidFile);
334
- if (!pid) return null;
348
+ if (pid && Daemon._isAlive(pid)) return pid;
335
349
 
336
- if (!Daemon._isAlive(pid)) {
337
- Daemon._cleanupStaleDaemonFiles(pidFile, statusFile);
338
- return null;
339
- }
350
+ // pid file missing or stale — fall back to the status file so `agn status`
351
+ // reports the SAME live daemon the `agn up` singleton guard detects.
352
+ // Otherwise status says "not running" (dead pid file) while up refuses
353
+ // ("already running", from the status file) — the exact contradiction that
354
+ // hid today's orphaned daemon.
355
+ const live = Daemon.runningDaemonPid(configDir);
356
+ if (live) return live;
340
357
 
341
- return pid;
358
+ Daemon._cleanupStaleDaemonFiles(pidFile, statusFile);
359
+ return null;
342
360
  }
343
361
 
344
362
  // ---------------------------------------------------------------------------
@@ -997,12 +1015,27 @@ class Daemon {
997
1015
  * stopped for a legitimate restart doesn't block the replacement.
998
1016
  */
999
1017
  static runningDaemonPid(configDir) {
1018
+ return Daemon._liveDaemonPids(configDir)[0] || null;
1019
+ }
1020
+
1021
+ /**
1022
+ * Return all distinct live daemon PIDs for this configDir (never including
1023
+ * the calling process), gathered from BOTH the pid file and the status file.
1024
+ * The pid file is considered first so its PID sorts ahead of the status
1025
+ * file's. Used by the singleton guard, `agn status`, and `agn down` so every
1026
+ * command agrees on which process(es) are the daemon — the divergence that
1027
+ * let a stale pid file orphan a running daemon.
1028
+ */
1029
+ static _liveDaemonPids(configDir) {
1000
1030
  const self = process.pid;
1001
- const isOtherAlive = (pid) =>
1002
- pid && pid !== self && Daemon._isAlive(pid);
1031
+ const pids = [];
1032
+ const consider = (pid) => {
1033
+ if (pid && pid !== self && Daemon._isAlive(pid) && !pids.includes(pid)) {
1034
+ pids.push(pid);
1035
+ }
1036
+ };
1003
1037
 
1004
- const pidFromFile = Daemon._readPid(path.join(configDir, 'daemon.pid'));
1005
- if (isOtherAlive(pidFromFile)) return pidFromFile;
1038
+ consider(Daemon._readPid(path.join(configDir, 'daemon.pid')));
1006
1039
 
1007
1040
  try {
1008
1041
  const statusFile = path.join(configDir, 'daemon.status.json');
@@ -1011,11 +1044,11 @@ class Daemon {
1011
1044
  // unrelated process can't masquerade as a live daemon.
1012
1045
  if (age < 30000) {
1013
1046
  const raw = JSON.parse(fs.readFileSync(statusFile, 'utf-8'));
1014
- const pid = raw && raw.pid;
1015
- if (isOtherAlive(pid)) return pid;
1047
+ consider(raw && raw.pid);
1016
1048
  }
1017
1049
  } catch {}
1018
- return null;
1050
+
1051
+ return pids;
1019
1052
  }
1020
1053
  }
1021
1054
 
package/src/installer.js CHANGED
@@ -356,6 +356,57 @@ class Installer {
356
356
  );
357
357
  }
358
358
 
359
+ // Hermes's install.ps1 can print "[X] Installation failed" (e.g. its bundled
360
+ // `uv` step fails) yet still exit 0, so exit-code-only success is unreliable.
361
+ // Confirm a real hermes binary exists before recording the install. Mirrors
362
+ // the paths the HermesAdapter._findHermesBinary() searches.
363
+ _verifyHermesBinary() {
364
+ try { clearBinaryLookupCache(); } catch {}
365
+ const isWin = process.platform === 'win32';
366
+ const home = os.homedir();
367
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
368
+ const candidates = [];
369
+ const resolved = this._whichBinary('hermes');
370
+ if (resolved) candidates.push(resolved);
371
+ if (isWin) {
372
+ candidates.push(
373
+ path.join(localAppData, 'hermes', 'hermes-agent', 'venv', 'Scripts', 'hermes.exe'),
374
+ path.join(localAppData, 'hermes', 'hermes-agent', 'venv', 'Scripts', 'hermes.cmd'),
375
+ path.join(localAppData, 'hermes', 'bin', 'hermes.exe'),
376
+ path.join(localAppData, 'hermes', 'bin', 'hermes.cmd'),
377
+ path.join(home, '.hermes', 'bin', 'hermes.exe'),
378
+ path.join(home, '.hermes', 'bin', 'hermes.cmd'),
379
+ path.join(home, '.local', 'bin', 'hermes.exe'),
380
+ path.join(home, '.local', 'bin', 'hermes.cmd'),
381
+ );
382
+ } else {
383
+ candidates.push(
384
+ path.join(home, '.hermes', 'bin', 'hermes'),
385
+ path.join(home, '.local', 'bin', 'hermes'),
386
+ '/opt/homebrew/bin/hermes',
387
+ '/usr/local/bin/hermes',
388
+ );
389
+ }
390
+ let found = null;
391
+ for (const c of candidates) {
392
+ try { if (c && fs.existsSync(c)) { found = c; break; } } catch {}
393
+ }
394
+ return found ? { path: found } : null;
395
+ }
396
+
397
+ _hermesBinaryNotFoundMessage() {
398
+ const isWin = process.platform === 'win32';
399
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
400
+ const expected = isWin
401
+ ? path.join(localAppData, 'hermes', 'hermes-agent', 'venv', 'Scripts', 'hermes.exe')
402
+ : path.join(os.homedir(), '.hermes', 'bin', 'hermes');
403
+ return (
404
+ 'Hermes install command completed, but the Hermes CLI binary could not be found\n' +
405
+ "(its installer can report a uv/setup failure yet still exit 0).\n\n" +
406
+ `Expected path:\n${expected}`
407
+ );
408
+ }
409
+
359
410
  /**
360
411
  * Find the binary path for an agent type.
361
412
  */
@@ -953,6 +1004,18 @@ class Installer {
953
1004
  }
954
1005
  if (onData) onData(`\nAmp CLI resolved: ${amp.path}${amp.version ? ` (${amp.version})` : ''}\n`);
955
1006
  }
1007
+ // Hermes-only: its install.ps1 can exit 0 after a failed uv/setup step,
1008
+ // so verify a real binary exists before recording the install.
1009
+ if (agentType === 'hermes') {
1010
+ const hermes = this._verifyHermesBinary();
1011
+ if (!hermes) {
1012
+ const msg = this._hermesBinaryNotFoundMessage();
1013
+ if (onData) onData(`\n${msg}\n`);
1014
+ reject(new Error(msg));
1015
+ return;
1016
+ }
1017
+ if (onData) onData(`\nHermes CLI resolved: ${hermes.path}\n`);
1018
+ }
956
1019
  this._markInstalled(agentType);
957
1020
  if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
958
1021
  resolve({ success: true, command: displayCmd });
@@ -88,20 +88,31 @@ async function checkForUpdate() {
88
88
  }
89
89
 
90
90
  /**
91
- * Walk up from __dirname until we find the enclosing node_modules; the
92
- * directory containing node_modules is the install prefix.
91
+ * Derive the npm `--prefix` for a global install from the npm binary location.
92
+ *
93
+ * npm resolves `-g` installs relative to a PREFIX, and the layout differs by
94
+ * platform:
95
+ * Unix: packages → {prefix}/lib/node_modules (npm bin at {prefix}/bin)
96
+ * Windows: packages → {prefix}/node_modules (npm bin at {prefix})
97
+ *
98
+ * So the prefix is the PARENT of npm's bin dir on Unix, and the bin dir itself
99
+ * on Windows. The previous implementation walked up to the enclosing
100
+ * node_modules and returned the directory containing it — which on Unix is
101
+ * `{prefix}/lib`. Passing THAT as `--prefix` made npm append `lib/node_modules`
102
+ * again, installing into `{prefix}/lib/lib/node_modules` while the running `agn`
103
+ * (loaded from `{prefix}/lib/node_modules`) never changed: `agn update` reported
104
+ * success but silently no-op'd on every Unix host.
93
105
  */
94
- function detectInstallPrefix() {
95
- let dir = __dirname;
96
- for (let i = 0; i < 10; i++) {
97
- const parent = path.dirname(dir);
98
- if (parent === dir) break;
99
- if (path.basename(parent) === 'node_modules') {
100
- return path.dirname(parent);
101
- }
102
- dir = parent;
103
- }
104
- return null;
106
+ function npmPrefixFromBin(npmBin, platform = process.platform) {
107
+ if (!npmBin) return null;
108
+ // Use the target platform's path semantics so a Windows path resolves
109
+ // correctly even when this runs on a POSIX host (and vice versa).
110
+ const p = platform === 'win32' ? path.win32 : path.posix;
111
+ const binDir = p.dirname(npmBin);
112
+ // A bare name (e.g. 'npm' from the PATH hard-fallback) has no usable
113
+ // directory — let npm resolve its own default global prefix instead.
114
+ if (binDir === '.' || binDir === '') return null;
115
+ return platform === 'win32' ? binDir : p.dirname(binDir);
105
116
  }
106
117
 
107
118
  /**
@@ -139,7 +150,7 @@ function runUpdate(opts = {}) {
139
150
  const platform = opts.platform || process.platform;
140
151
  const spawn = opts.spawn || spawnSync;
141
152
  const npmBin = opts.npmBin || findNpmBin({ platform });
142
- const prefix = opts.prefix !== undefined ? opts.prefix : detectInstallPrefix();
153
+ const prefix = opts.prefix !== undefined ? opts.prefix : npmPrefixFromBin(npmBin, platform);
143
154
  // Use a GLOBAL install (-g). A LOCAL install (`npm install <pkg> --prefix
144
155
  // <dir>`) treats <dir> as a project root and prunes every node_modules entry
145
156
  // not reachable from its package.json. When the prefix is the portable node
@@ -228,5 +239,6 @@ module.exports = {
228
239
  notifyAndMaybeUpdate,
229
240
  runUpdate,
230
241
  findNpmBin,
242
+ npmPrefixFromBin,
231
243
  currentVersion,
232
244
  };
@@ -156,15 +156,12 @@ class WorkspaceClient {
156
156
  */
157
157
  async sendMessage(workspaceId, channelName, token, content, {
158
158
  senderType = 'agent', senderName, messageType = 'chat', metadata, attachments, sessionId,
159
- spec, specToolCallId,
160
159
  } = {}) {
161
160
  const sourcePrefix = senderType === 'agent' ? 'openagents' : 'human';
162
161
  const source = senderName ? `${sourcePrefix}:${senderName}` : `${sourcePrefix}:unknown`;
163
162
 
164
163
  const payload = { content, message_type: messageType };
165
164
  if (attachments && attachments.length) payload.attachments = attachments;
166
- if (spec) payload.spec = spec;
167
- if (specToolCallId) payload.spec_tool_call_id = specToolCallId;
168
165
 
169
166
  return this.sendEvent(workspaceId, {
170
167
  type: 'workspace.message.posted',
@@ -175,30 +172,6 @@ class WorkspaceClient {
175
172
  }, token, sessionId);
176
173
  }
177
174
 
178
- /**
179
- * Poll workspace.tool_result events — the client's response to an agent's
180
- * render_ui invocation. Returns events directly (no message normalization)
181
- * because the caller treats them differently than chat messages.
182
- */
183
- async pollToolResults(workspaceId, token, { after, limit = 50 } = {}) {
184
- const params = new URLSearchParams({
185
- network: workspaceId,
186
- type: 'workspace.tool_result',
187
- limit: String(limit),
188
- });
189
- if (after) params.set('after', after);
190
-
191
- const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
192
- const result = data.data || data;
193
- const events = (result && result.events) || [];
194
-
195
- let cursor = null;
196
- if (events.length > 0) {
197
- cursor = events[events.length - 1].id || null;
198
- }
199
- return { events, cursor };
200
- }
201
-
202
175
  /**
203
176
  * Poll messages in a channel via GET /v1/events.
204
177
  * @returns {Array} message-compatible objects
@@ -275,6 +248,11 @@ class WorkspaceClient {
275
248
  const params = new URLSearchParams({
276
249
  network: workspaceId,
277
250
  type: 'workspace.message.posted',
251
+ // Server-side pre-filter: only return events routed to this agent (plus
252
+ // untargeted ones) instead of the whole network's traffic. Backward
253
+ // compatible — older backends ignore the unknown param and return
254
+ // everything, so the client-side filter below stays as the safety net.
255
+ target_agents: agentName,
278
256
  limit: String(limit),
279
257
  });
280
258
  if (after) params.set('after', after);
@@ -283,12 +261,23 @@ class WorkspaceClient {
283
261
  const result = data.data || data;
284
262
  const events = (result && result.events) || [];
285
263
 
264
+ // Prefer the server's next_cursor: with server-side target filtering the
265
+ // last returned event lags the stream tip, so advancing by it alone would
266
+ // re-scan the same range every poll. next_cursor jumps past other agents'
267
+ // events once ours are drained. Older backends don't send it — fall back
268
+ // to the last returned event id (they return the full stream, so that IS
269
+ // the tip).
286
270
  let cursor = null;
287
- if (events.length > 0) {
271
+ if (result && result.next_cursor) {
272
+ cursor = result.next_cursor;
273
+ } else if (events.length > 0) {
288
274
  cursor = events[events.length - 1].id || null;
289
275
  }
290
276
 
291
- // Filter for messages targeted at this agent.
277
+ // Filter for messages targeted at this agent. With a targeting-aware
278
+ // backend this is mostly a no-op (server already narrowed the set); it
279
+ // remains authoritative for older backends and for the untargeted events
280
+ // the server includes as a safe superset.
292
281
  //
293
282
  // target_agents semantics:
294
283
  // • absent → legacy server with no routing decision
@@ -802,6 +791,14 @@ class WorkspaceClient {
802
791
  method: 'GET',
803
792
  headers,
804
793
  timeout,
794
+ // Hard end-to-end deadline. The `timeout` option above is only a SOCKET
795
+ // inactivity timeout — it is NOT armed until a socket exists, so it does
796
+ // not bound DNS resolution or TCP connect. During a network partition a
797
+ // request stuck in getaddrinfo/connect never fires 'timeout', so the
798
+ // await never settles and the caller's single poll loop wedges forever
799
+ // (the agent stays "online" via its separate heartbeat but stops
800
+ // consuming messages). AbortSignal.timeout fires in ANY phase.
801
+ signal: AbortSignal.timeout(timeout),
805
802
  }, (res) => {
806
803
  let data = '';
807
804
  res.on('data', (chunk) => { data += chunk; });
@@ -836,6 +833,7 @@ class WorkspaceClient {
836
833
  method: 'GET',
837
834
  headers,
838
835
  timeout,
836
+ signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
839
837
  }, (res) => {
840
838
  const chunks = [];
841
839
  res.on('data', (chunk) => { chunks.push(chunk); });
@@ -868,6 +866,7 @@ class WorkspaceClient {
868
866
  method: 'POST',
869
867
  headers: { ...headers, 'Content-Length': Buffer.byteLength(jsonBody) },
870
868
  timeout,
869
+ signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
871
870
  }, (res) => {
872
871
  let data = '';
873
872
  res.on('data', (chunk) => { data += chunk; });
@@ -910,6 +909,7 @@ class WorkspaceClient {
910
909
  method: 'PUT',
911
910
  headers: { ...headers, 'Content-Length': Buffer.byteLength(jsonBody) },
912
911
  timeout,
912
+ signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
913
913
  }, (res) => {
914
914
  let data = '';
915
915
  res.on('data', (chunk) => { data += chunk; });
@@ -952,6 +952,7 @@ class WorkspaceClient {
952
952
  method: 'PATCH',
953
953
  headers: { ...headers, 'Content-Length': Buffer.byteLength(jsonBody) },
954
954
  timeout,
955
+ signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
955
956
  }, (res) => {
956
957
  let data = '';
957
958
  res.on('data', (chunk) => { data += chunk; });
@@ -987,6 +988,7 @@ class WorkspaceClient {
987
988
  method: 'DELETE',
988
989
  headers,
989
990
  timeout: 15000,
991
+ signal: AbortSignal.timeout(15000), // hard deadline (covers DNS/connect); see _get
990
992
  }, (res) => {
991
993
  let data = '';
992
994
  res.on('data', (chunk) => { data += chunk; });