@openagents-org/agent-launcher 0.2.153 → 0.2.154

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.153",
3
+ "version": "0.2.154",
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
@@ -396,6 +396,76 @@ async function cmdConnect(connector, flags, positional) {
396
396
  }
397
397
  }
398
398
 
399
+ async function cmdNode(connector, flags, positional) {
400
+ const sub = positional[0] || 'status';
401
+ const nodeCfg = require('./node-config');
402
+
403
+ if (sub === 'connect') {
404
+ const code = positional[1] || flags.code;
405
+ if (!code) {
406
+ print('Usage: agn node connect <pairing-code>');
407
+ print('Get a pairing code from your workspace: Connect a Node.');
408
+ process.exitCode = 1;
409
+ return;
410
+ }
411
+ const info = nodeCfg.gatherDeviceInfo();
412
+ if (flags.type) info.deviceType = flags.type; // server | laptop | desktop
413
+ if (flags.name) info.name = flags.name;
414
+
415
+ print('Connecting this device to your workspace...');
416
+ try {
417
+ const res = await connector.redeemNodePairingCode(code, info);
418
+ nodeCfg.saveNode({
419
+ node_id: res.nodeId,
420
+ node_key: info.nodeKey,
421
+ workspace_id: res.workspaceId,
422
+ workspace_slug: res.workspaceSlug,
423
+ endpoint: connector.workspace.endpoint,
424
+ token: res.token,
425
+ });
426
+ // Register the workspace locally so agents created on this node can use it.
427
+ connector.config.addNetwork({
428
+ id: res.workspaceId,
429
+ slug: res.workspaceSlug,
430
+ name: res.workspaceName,
431
+ endpoint: connector.workspace.endpoint,
432
+ token: res.token,
433
+ });
434
+ print(`Node connected to workspace '${res.workspaceName}' (${res.workspaceSlug})`);
435
+ print(` This device: ${info.hostname} (${info.deviceType})`);
436
+
437
+ // Start the daemon so the node heartbeats and agents can run.
438
+ if (connector.getDaemonPid()) {
439
+ print(' Daemon already running.');
440
+ } else {
441
+ print(' Starting daemon...');
442
+ connector.startDaemon();
443
+ }
444
+ print('');
445
+ print('Next: add an agent from the workspace, or run:');
446
+ print(' agn create <name> --type <type> --install');
447
+ } catch (e) {
448
+ print(`Error: ${e.message}`);
449
+ process.exitCode = 1;
450
+ }
451
+ return;
452
+ }
453
+
454
+ if (sub === 'status') {
455
+ const n = nodeCfg.loadNode();
456
+ if (n && n.node_id) {
457
+ print(`Node connected: ${n.node_id}`);
458
+ print(` Workspace: ${n.workspace_slug || n.workspace_id}`);
459
+ } else {
460
+ print('No node connected. Run: agn node connect <pairing-code>');
461
+ }
462
+ return;
463
+ }
464
+
465
+ print('Usage: agn node <connect|status> [pairing-code]');
466
+ process.exitCode = 1;
467
+ }
468
+
399
469
  async function cmdDisconnect(connector, _flags, positional) {
400
470
  const name = positional[0];
401
471
  if (!name) { print('Usage: agn disconnect <agent-name>'); return; }
@@ -830,6 +900,7 @@ async function main() {
830
900
  search: () => cmdSearch(connector, flags, positional),
831
901
  runtimes: () => cmdRuntimes(connector),
832
902
  connect: () => cmdConnect(connector, flags, positional),
903
+ node: () => cmdNode(connector, flags, positional),
833
904
  disconnect: () => cmdDisconnect(connector, flags, positional),
834
905
  logs: () => cmdLogs(connector, flags, positional),
835
906
  autostart: () => cmdAutostart(connector, flags),
package/src/daemon.js CHANGED
@@ -30,9 +30,31 @@ class Daemon {
30
30
  this._shuttingDown = false;
31
31
  this._statusInterval = null;
32
32
  this._cmdInterval = null;
33
+ this._nodeHeartbeatInterval = null; // device-level heartbeat (connect-a-node)
34
+ this._nodeClient = null; // lazily-created WorkspaceClient for the node
33
35
  this._reloadInFlight = null; // serialize concurrent _reload() calls
34
36
  }
35
37
 
38
+ /**
39
+ * Heartbeat this device (node) to its workspace, independent of any agent, so
40
+ * the workspace's "connected devices" view stays live even with zero agents.
41
+ * Best-effort: node liveness is non-critical to agent operation.
42
+ */
43
+ async _nodeHeartbeat() {
44
+ try {
45
+ const nodeCfg = require('./node-config');
46
+ const n = nodeCfg.loadNode();
47
+ if (!n || !n.node_id || !n.token) return; // no node connected
48
+ if (!this._nodeClient) {
49
+ const { WorkspaceClient } = require('./workspace-client');
50
+ this._nodeClient = new WorkspaceClient(n.endpoint);
51
+ }
52
+ await this._nodeClient.nodeHeartbeat(n.node_id, n.token, nodeCfg.gatherDeviceInfo());
53
+ } catch {
54
+ // ignore — will retry next interval
55
+ }
56
+ }
57
+
36
58
  // ---------------------------------------------------------------------------
37
59
  // Public API
38
60
  // ---------------------------------------------------------------------------
@@ -91,6 +113,10 @@ class Daemon {
91
113
  this._processCommands();
92
114
  }, 200);
93
115
 
116
+ // Device-level heartbeat (connect-a-node), independent of agents.
117
+ this._nodeHeartbeat();
118
+ this._nodeHeartbeatInterval = setInterval(() => this._nodeHeartbeat(), 30000);
119
+
94
120
  // Watch config file for hot-reload
95
121
  this._watchConfig();
96
122
 
@@ -116,6 +142,7 @@ class Daemon {
116
142
 
117
143
  if (this._statusInterval) clearInterval(this._statusInterval);
118
144
  if (this._cmdInterval) clearInterval(this._cmdInterval);
145
+ if (this._nodeHeartbeatInterval) clearInterval(this._nodeHeartbeatInterval);
119
146
  if (this._configWatcher) { try { this._configWatcher.close(); } catch {} }
120
147
 
121
148
  // Kill all child processes
package/src/index.js CHANGED
@@ -275,6 +275,10 @@ class AgentConnector {
275
275
  return this.workspace.resolveToken(token);
276
276
  }
277
277
 
278
+ async redeemNodePairingCode(code, deviceInfo) {
279
+ return this.workspace.redeemPairingCode(code, deviceInfo);
280
+ }
281
+
278
282
  // -- LLM test --
279
283
 
280
284
  async testLLM(env) {
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Node identity for "connect a node" (~/.openagents/node.json).
5
+ *
6
+ * A node is this device's registration with a workspace, independent of any
7
+ * agent. Stores the stable device key, the assigned node id, and the workspace
8
+ * binding + token so the daemon can heartbeat the node.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+ const crypto = require('crypto');
15
+
16
+ const NODE_DIR = path.join(os.homedir(), '.openagents');
17
+ const NODE_FILE = path.join(NODE_DIR, 'node.json');
18
+
19
+ function loadNode() {
20
+ try {
21
+ if (fs.existsSync(NODE_FILE)) return JSON.parse(fs.readFileSync(NODE_FILE, 'utf-8'));
22
+ } catch {}
23
+ return null;
24
+ }
25
+
26
+ function saveNode(data) {
27
+ try {
28
+ fs.mkdirSync(NODE_DIR, { recursive: true });
29
+ fs.writeFileSync(NODE_FILE, JSON.stringify(data, null, 2));
30
+ try { fs.chmodSync(NODE_FILE, 0o600); } catch {} // holds the workspace token
31
+ } catch {}
32
+ }
33
+
34
+ /** Stable per-device id, generated once and persisted. */
35
+ function getOrCreateNodeKey() {
36
+ const existing = loadNode();
37
+ if (existing && existing.node_key) return existing.node_key;
38
+ const key = (crypto.randomUUID && crypto.randomUUID()) || crypto.randomBytes(16).toString('hex');
39
+ saveNode({ ...(existing || {}), node_key: key });
40
+ return key;
41
+ }
42
+
43
+ /** Best-effort device type from the platform (user/UI can override). */
44
+ function inferDeviceType() {
45
+ const p = os.platform();
46
+ if (p === 'darwin') return 'laptop';
47
+ if (p === 'win32') return 'desktop';
48
+ return 'server'; // linux is typically a server/VM in this context
49
+ }
50
+
51
+ /** Device info sent on redeem/heartbeat. */
52
+ function gatherDeviceInfo() {
53
+ let launcherVersion = '';
54
+ try { launcherVersion = require('../package.json').version; } catch {}
55
+ return {
56
+ nodeKey: getOrCreateNodeKey(),
57
+ hostname: os.hostname(),
58
+ os: os.platform(),
59
+ deviceType: inferDeviceType(),
60
+ launcherVersion,
61
+ };
62
+ }
63
+
64
+ module.exports = {
65
+ loadNode,
66
+ saveNode,
67
+ getOrCreateNodeKey,
68
+ inferDeviceType,
69
+ gatherDeviceInfo,
70
+ NODE_FILE,
71
+ NODE_DIR,
72
+ };
@@ -107,6 +107,36 @@ class WorkspaceClient {
107
107
  return data.data || data;
108
108
  }
109
109
 
110
+ /**
111
+ * Redeem a node pairing code via POST /v1/nodes/redeem. The code is the
112
+ * credential (no auth header). Returns { nodeId, workspaceId, workspaceSlug,
113
+ * workspaceName, token } — the token is the workspace's machine credential.
114
+ */
115
+ async redeemPairingCode(code, deviceInfo = {}) {
116
+ const body = { code, node_key: deviceInfo.nodeKey };
117
+ if (deviceInfo.name) body.name = deviceInfo.name;
118
+ if (deviceInfo.hostname) body.hostname = deviceInfo.hostname;
119
+ if (deviceInfo.deviceType) body.device_type = deviceInfo.deviceType;
120
+ if (deviceInfo.os) body.os = deviceInfo.os;
121
+ if (deviceInfo.launcherVersion) body.launcher_version = deviceInfo.launcherVersion;
122
+ const data = await this._post('/v1/nodes/redeem', body);
123
+ return data.data || data;
124
+ }
125
+
126
+ /**
127
+ * Node liveness via POST /v1/nodes/heartbeat (authenticated by the workspace
128
+ * token). Independent of any agent heartbeat.
129
+ */
130
+ async nodeHeartbeat(nodeId, token, deviceInfo = {}) {
131
+ const body = { node_id: nodeId };
132
+ if (deviceInfo.hostname) body.hostname = deviceInfo.hostname;
133
+ if (deviceInfo.deviceType) body.device_type = deviceInfo.deviceType;
134
+ if (deviceInfo.os) body.os = deviceInfo.os;
135
+ if (deviceInfo.launcherVersion) body.launcher_version = deviceInfo.launcherVersion;
136
+ const data = await this._post('/v1/nodes/heartbeat', body, this._wsHeaders(token));
137
+ return data.data || data;
138
+ }
139
+
110
140
  /**
111
141
  * Send heartbeat via POST /v1/heartbeat.
112
142
  *