@openagents-org/agent-launcher 0.2.154 → 0.2.156

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.154",
3
+ "version": "0.2.156",
4
4
  "description": "OpenAgents Launcher \u2014 install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -434,13 +434,20 @@ async function cmdNode(connector, flags, positional) {
434
434
  print(`Node connected to workspace '${res.workspaceName}' (${res.workspaceSlug})`);
435
435
  print(` This device: ${info.hostname} (${info.deviceType})`);
436
436
 
437
- // Start the daemon so the node heartbeats and agents can run.
437
+ // (Re)start the daemon so it runs the CURRENT launcher build and loads
438
+ // the new node identity. A daemon started before the connect-a-node
439
+ // feature has no node-heartbeat loop, so reusing it would leave the node
440
+ // showing offline — recycle it rather than reuse it.
438
441
  if (connector.getDaemonPid()) {
439
- print(' Daemon already running.');
442
+ print(' Restarting daemon...');
443
+ connector.stopDaemon();
444
+ for (let i = 0; i < 25 && connector.getDaemonPid(); i++) {
445
+ await new Promise((resolve) => setTimeout(resolve, 200));
446
+ }
440
447
  } else {
441
448
  print(' Starting daemon...');
442
- connector.startDaemon();
443
449
  }
450
+ connector.startDaemon();
444
451
  print('');
445
452
  print('Next: add an agent from the workspace, or run:');
446
453
  print(' agn create <name> --type <type> --install');
@@ -775,7 +782,7 @@ async function cmdVersion() {
775
782
  print(`${pkg.name} v${pkg.version}`);
776
783
  }
777
784
 
778
- async function cmdUpdate() {
785
+ async function cmdUpdate(connector) {
779
786
  const { checkForUpdate, runUpdate, currentVersion } = require('./update-check');
780
787
  const info = await checkForUpdate();
781
788
  if (!info) {
@@ -795,6 +802,23 @@ async function cmdUpdate() {
795
802
  return;
796
803
  }
797
804
  print(`Updated to ${info.latest}.`);
805
+
806
+ // Recycle a running daemon so the new build takes effect immediately. Without
807
+ // this, a long-lived daemon keeps executing the OLD in-memory code (and misses
808
+ // new features) until the next manual restart or reboot — the failure mode
809
+ // that left a connected node showing offline after its launcher was upgraded.
810
+ try {
811
+ if (connector && connector.getDaemonPid()) {
812
+ print('Restarting daemon to apply the update...');
813
+ connector.stopDaemon();
814
+ for (let i = 0; i < 25 && connector.getDaemonPid(); i++) {
815
+ await new Promise((resolve) => setTimeout(resolve, 200));
816
+ }
817
+ connector.startDaemon();
818
+ }
819
+ } catch (e) {
820
+ print(`Note: could not restart the daemon automatically (${e.message}). Run 'agn restart'.`);
821
+ }
798
822
  }
799
823
 
800
824
  async function cmdHelp() {
@@ -909,7 +933,7 @@ async function main() {
909
933
  skills: () => cmdSkills(connector, flags, positional),
910
934
  'tool-mode': () => cmdToolMode(connector, flags, positional),
911
935
  'test-llm': () => cmdTestLLM(connector, flags, positional),
912
- update: () => cmdUpdate(),
936
+ update: () => cmdUpdate(connector),
913
937
  'mcp-server': () => {
914
938
  const { runMcpServer } = require('./mcp-server');
915
939
  const workspaceId = flags['workspace-id'] || process.env.OPENAGENTS_WORKSPACE_ID;
package/src/daemon.js CHANGED
@@ -32,6 +32,7 @@ class Daemon {
32
32
  this._cmdInterval = null;
33
33
  this._nodeHeartbeatInterval = null; // device-level heartbeat (connect-a-node)
34
34
  this._nodeClient = null; // lazily-created WorkspaceClient for the node
35
+ this._runningCommands = new Set(); // node-command ids currently executing
35
36
  this._reloadInFlight = null; // serialize concurrent _reload() calls
36
37
  }
37
38
 
@@ -46,15 +47,116 @@ class Daemon {
46
47
  const n = nodeCfg.loadNode();
47
48
  if (!n || !n.node_id || !n.token) return; // no node connected
48
49
  if (!this._nodeClient) {
49
- const { WorkspaceClient } = require('./workspace-client');
50
50
  this._nodeClient = new WorkspaceClient(n.endpoint);
51
51
  }
52
- await this._nodeClient.nodeHeartbeat(n.node_id, n.token, nodeCfg.gatherDeviceInfo());
52
+ const info = { ...nodeCfg.gatherDeviceInfo(), agents: this._buildRoster() };
53
+ const resp = await this._nodeClient.nodeHeartbeat(n.node_id, n.token, info);
54
+ // The heartbeat response is our push channel: run any queued remote
55
+ // agent-management commands the workspace enqueued for this node. Fire
56
+ // them off without blocking the heartbeat loop (an install can take
57
+ // minutes — awaiting here would stall liveness and stack up commands).
58
+ const commands = (resp && resp.commands) || [];
59
+ for (const cmd of commands) {
60
+ const id = cmd.commandId;
61
+ if (!id || this._runningCommands.has(id)) continue;
62
+ this._runningCommands.add(id);
63
+ this._runNodeCommand(n, cmd).finally(() => this._runningCommands.delete(id));
64
+ }
53
65
  } catch {
54
66
  // ignore — will retry next interval
55
67
  }
56
68
  }
57
69
 
70
+ /** Roster of agents this node hosts, for the workspace's node view. */
71
+ _buildRoster() {
72
+ const roster = [];
73
+ try {
74
+ const status = this.getStatus();
75
+ for (const [name, info] of Object.entries(status)) {
76
+ roster.push({ name, type: info.type || 'unknown', status: info.state || 'unknown' });
77
+ }
78
+ } catch {
79
+ // best-effort
80
+ }
81
+ return roster;
82
+ }
83
+
84
+ /**
85
+ * Execute one remote command by shelling out to this same launcher's CLI, so
86
+ * all of create/install/connect/start/stop/remove reuses the exact code path a
87
+ * local `agn` invocation would — then report the outcome back to the workspace.
88
+ */
89
+ async _runNodeCommand(n, cmd) {
90
+ const action = cmd.action;
91
+ const args = cmd.args || {};
92
+ const name = (args.name || '').trim();
93
+ let ok = false;
94
+ let message = '';
95
+ try {
96
+ if (action === 'create_agent') {
97
+ const type = (args.type || '').trim();
98
+ const r1 = await this._runAgn(['create', name, '--type', type, '--install']);
99
+ if (r1.code !== 0) throw new Error(r1.stderr || r1.stdout || 'create failed');
100
+ // Optional credentials for API-key agents (generic → provider mapping
101
+ // happens in env resolution).
102
+ if (args.apiKey) await this._runAgn(['env', type, '--set', `LLM_API_KEY=${args.apiKey}`]);
103
+ if (args.model) await this._runAgn(['env', type, '--set', `LLM_MODEL=${args.model}`]);
104
+ if (args.baseUrl) await this._runAgn(['env', type, '--set', `LLM_BASE_URL=${args.baseUrl}`]);
105
+ // Attach to this node's workspace so the agent shows up as a member.
106
+ const r2 = await this._runAgn(['connect', name, n.token, '--endpoint', n.endpoint]);
107
+ if (r2.code !== 0) throw new Error(r2.stderr || r2.stdout || 'connect failed');
108
+ ok = true;
109
+ message = `Agent '${name}' created`;
110
+ } else if (action === 'start_agent') {
111
+ const r = await this._runAgn(['start', name]);
112
+ ok = r.code === 0;
113
+ message = ok ? `Agent '${name}' started` : (r.stderr || r.stdout || 'start failed');
114
+ } else if (action === 'stop_agent') {
115
+ const r = await this._runAgn(['stop', name]);
116
+ ok = r.code === 0;
117
+ message = ok ? `Agent '${name}' stopped` : (r.stderr || r.stdout || 'stop failed');
118
+ } else if (action === 'remove_agent') {
119
+ const r = await this._runAgn(['remove', name]);
120
+ ok = r.code === 0;
121
+ message = ok ? `Agent '${name}' removed` : (r.stderr || r.stdout || 'remove failed');
122
+ } else {
123
+ message = `Unknown action '${action}'`;
124
+ }
125
+ } catch (e) {
126
+ ok = false;
127
+ message = e.message || String(e);
128
+ }
129
+ try {
130
+ await this._nodeClient.nodeCommandResult(cmd.commandId, n.token, { ok, message });
131
+ } catch {
132
+ // best-effort; the command stays 'running' if we can't report back
133
+ }
134
+ }
135
+
136
+ /** Run this launcher's own CLI as a child process, capturing output. */
137
+ _runAgn(cliArgs) {
138
+ return new Promise((resolve) => {
139
+ let bin;
140
+ try {
141
+ bin = require.resolve('../bin/agent-connector.js');
142
+ } catch {
143
+ bin = process.argv[1];
144
+ }
145
+ const child = spawn(process.execPath, [bin, ...cliArgs], {
146
+ env: { ...getEnhancedEnv(), OPENAGENTS_SKIP_UPDATE_CHECK: '1' },
147
+ // Run from home so a created agent's default working dir is sensible
148
+ // (not the ~/.openagents config dir).
149
+ cwd: os.homedir(),
150
+ });
151
+ let stdout = '';
152
+ let stderr = '';
153
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
154
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
155
+ child.on('error', (e) => resolve({ code: 1, stdout, stderr: stderr || e.message }));
156
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
157
+ });
158
+ }
159
+
58
160
  // ---------------------------------------------------------------------------
59
161
  // Public API
60
162
  // ---------------------------------------------------------------------------
@@ -113,9 +215,11 @@ class Daemon {
113
215
  this._processCommands();
114
216
  }, 200);
115
217
 
116
- // Device-level heartbeat (connect-a-node), independent of agents.
218
+ // Device-level heartbeat (connect-a-node), independent of agents. Also the
219
+ // delivery channel for remote agent-management commands, so keep it brisk
220
+ // (10s) — the request is tiny and it bounds remote-command pickup latency.
117
221
  this._nodeHeartbeat();
118
- this._nodeHeartbeatInterval = setInterval(() => this._nodeHeartbeat(), 30000);
222
+ this._nodeHeartbeatInterval = setInterval(() => this._nodeHeartbeat(), 10000);
119
223
 
120
224
  // Watch config file for hot-reload
121
225
  this._watchConfig();
@@ -133,10 +133,24 @@ class WorkspaceClient {
133
133
  if (deviceInfo.deviceType) body.device_type = deviceInfo.deviceType;
134
134
  if (deviceInfo.os) body.os = deviceInfo.os;
135
135
  if (deviceInfo.launcherVersion) body.launcher_version = deviceInfo.launcherVersion;
136
+ if (Array.isArray(deviceInfo.agents)) body.agents = deviceInfo.agents;
136
137
  const data = await this._post('/v1/nodes/heartbeat', body, this._wsHeaders(token));
137
138
  return data.data || data;
138
139
  }
139
140
 
141
+ /**
142
+ * Report the outcome of a remote node command via
143
+ * POST /v1/nodes/commands/{id}/result (authenticated by the workspace token).
144
+ */
145
+ async nodeCommandResult(commandId, token, { ok, message } = {}) {
146
+ const data = await this._post(
147
+ `/v1/nodes/commands/${commandId}/result`,
148
+ { ok: !!ok, message: message || null },
149
+ this._wsHeaders(token),
150
+ );
151
+ return data.data || data;
152
+ }
153
+
140
154
  /**
141
155
  * Send heartbeat via POST /v1/heartbeat.
142
156
  *