@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 +1 -1
- package/src/cli.js +29 -5
- package/src/daemon.js +108 -4
- package/src/workspace-client.js +14 -0
package/package.json
CHANGED
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
|
-
//
|
|
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('
|
|
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
|
-
|
|
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(),
|
|
222
|
+
this._nodeHeartbeatInterval = setInterval(() => this._nodeHeartbeat(), 10000);
|
|
119
223
|
|
|
120
224
|
// Watch config file for hot-reload
|
|
121
225
|
this._watchConfig();
|
package/src/workspace-client.js
CHANGED
|
@@ -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
|
*
|