@openagents-org/agent-launcher 0.2.155 → 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.155",
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/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
  *