@openagents-org/agent-launcher 0.2.190 → 0.2.191

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.190",
3
+ "version": "0.2.191",
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
@@ -582,7 +582,17 @@ async function cmdNode(connector, flags, positional) {
582
582
  process.exitCode = 1;
583
583
  return;
584
584
  }
585
- const info = nodeCfg.gatherDeviceInfo();
585
+ let info;
586
+ try {
587
+ info = nodeCfg.gatherDeviceInfo();
588
+ } catch (e) {
589
+ // Pairing on top of an unreadable node.json would replace the pairings
590
+ // it holds; stop instead, so nothing is lost while it is sorted out.
591
+ print(`Could not read ${nodeCfg.NODE_FILE}: ${e.message}`);
592
+ print('Close anything that may have it open and try again.');
593
+ process.exitCode = 1;
594
+ return;
595
+ }
586
596
  if (flags.type) info.deviceType = flags.type; // server | laptop | desktop
587
597
  if (flags.name) info.name = flags.name;
588
598
 
package/src/daemon.js CHANGED
@@ -86,6 +86,15 @@ function _sentKey(args) {
86
86
  return present && !args.useDeviceCredentials;
87
87
  }
88
88
 
89
+ /**
90
+ * Whether a node heartbeat failure is the workspace saying the node is gone:
91
+ * a 404 carrying the backend's own message (routers/nodes.py), not any 404.
92
+ */
93
+ const NODE_GONE_MESSAGES = new Set(['Node not found', 'Workspace not found']);
94
+ function _nodeGone(err) {
95
+ return !!err && err.status === 404 && NODE_GONE_MESSAGES.has(err.message);
96
+ }
97
+
89
98
  class Daemon {
90
99
  constructor(config, envManager, registry) {
91
100
  this.config = config;
@@ -206,7 +215,9 @@ class Daemon {
206
215
  const nodeCfg = require('./node-config');
207
216
  const pairings = nodeCfg.listPairings().filter((p) => p && p.node_id && p.token);
208
217
  if (!pairings.length) return; // no node connected
209
- const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this._runtimes || [], fs: this._buildFs() };
218
+ // deviceInfo, not gatherDeviceInfo: the heartbeat sends no device key, and
219
+ // must never be the thing that writes node.json.
220
+ const info = { ...nodeCfg.deviceInfo(), runtimes: this._runtimes || [], fs: this._buildFs() };
210
221
  await Promise.all(pairings.map((p) => this._nodeHeartbeatOne(nodeCfg, p, info)));
211
222
  }
212
223
 
@@ -264,8 +275,12 @@ class Daemon {
264
275
  // Without this the launcher kept showing "connected" long after a remote
265
276
  // removal, since node.json was only ever reconciled by the UI on demand.
266
277
  // Transient failures (timeouts, 5xx, auth blips) say nothing about the
267
- // row's existence, so those are swallowed and retried next tick.
268
- if (err && err.status === 404) {
278
+ // row's existence, so those are swallowed and retried next tick. So is a
279
+ // 404 the backend did not write: the hosting platform answers every
280
+ // request with its own 404 ("Application not found") while the service
281
+ // is unreachable, and taking that as the workspace's word unpaired every
282
+ // workspace on the device at once.
283
+ if (_nodeGone(err)) {
269
284
  try { nodeCfg.clearPairing(n.workspace_id); } catch {}
270
285
  this._nodeClients.delete(n.workspace_id);
271
286
  this._log(`node ${n.node_id} no longer recognized by workspace ${n.workspace_slug || n.workspace_id} — pairing cleared`);
@@ -16,30 +16,64 @@ const crypto = require('crypto');
16
16
  const NODE_DIR = path.join(os.homedir(), '.openagents');
17
17
  const NODE_FILE = path.join(NODE_DIR, 'node.json');
18
18
 
19
- function loadNode() {
19
+ /**
20
+ * The record on disk: null when there is no file, and a throw when there is
21
+ * one that cannot be read or parsed (half-written by another process, or held
22
+ * by an antivirus scan on Windows). Every writer reads through this, so an
23
+ * unreadable file is left alone rather than rebuilt from nothing — which
24
+ * dropped every pairing and minted a new device key.
25
+ */
26
+ function readNode() {
27
+ let raw;
20
28
  try {
21
- if (fs.existsSync(NODE_FILE)) return JSON.parse(fs.readFileSync(NODE_FILE, 'utf-8'));
22
- } catch {}
23
- return null;
29
+ raw = fs.readFileSync(NODE_FILE, 'utf-8');
30
+ } catch (err) {
31
+ if (err && err.code === 'ENOENT') return null;
32
+ throw err;
33
+ }
34
+ return JSON.parse(raw);
24
35
  }
25
36
 
37
+ /** Lenient read for display and heartbeat: an unreadable file counts as none. */
38
+ function loadNode() {
39
+ try { return readNode(); } catch { return null; }
40
+ }
41
+
42
+ /**
43
+ * Written through a temp file and a rename, so a reader in the other process
44
+ * (launcher or daemon) sees the old record or the new one, never a torn one.
45
+ */
26
46
  function saveNode(data) {
27
47
  try {
28
48
  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
49
+ const tmp = `${NODE_FILE}.${process.pid}.tmp`;
50
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }); // holds the workspace token
51
+ try {
52
+ fs.renameSync(tmp, NODE_FILE);
53
+ } catch {
54
+ // Windows refuses the rename while another process holds the target.
55
+ try { fs.unlinkSync(tmp); } catch {}
56
+ fs.writeFileSync(NODE_FILE, JSON.stringify(data, null, 2));
57
+ }
58
+ try { fs.chmodSync(NODE_FILE, 0o600); } catch {}
31
59
  } catch {}
32
60
  }
33
61
 
34
- /** Stable per-device id, generated once and persisted. */
62
+ /** Stable per-device id, generated once and persisted. Throws if node.json is unreadable. */
35
63
  function getOrCreateNodeKey() {
36
- const existing = loadNode();
64
+ const existing = readNode();
37
65
  if (existing && existing.node_key) return existing.node_key;
38
66
  const key = (crypto.randomUUID && crypto.randomUUID()) || crypto.randomBytes(16).toString('hex');
39
67
  saveNode({ ...(existing || {}), node_key: key });
40
68
  return key;
41
69
  }
42
70
 
71
+ function pairingsOf(record) {
72
+ if (!record) return [];
73
+ if (record.pairings && record.pairings.length) return record.pairings;
74
+ return record.workspace_id ? [record] : [];
75
+ }
76
+
43
77
  /**
44
78
  * Every workspace this device is paired to, most recent first.
45
79
  *
@@ -49,10 +83,7 @@ function getOrCreateNodeKey() {
49
83
  * existed carry a single top-level pairing, which is reconstructed here.
50
84
  */
51
85
  function listPairings() {
52
- const record = loadNode();
53
- if (!record) return [];
54
- if (record.pairings && record.pairings.length) return record.pairings;
55
- return record.workspace_id ? [record] : [];
86
+ return pairingsOf(loadNode());
56
87
  }
57
88
 
58
89
  /**
@@ -67,7 +98,7 @@ function recordPairing(nodeKey, pairing) {
67
98
  const stamped = { ...pairing, paired_at: new Date().toISOString() };
68
99
  const pairings = [
69
100
  stamped,
70
- ...listPairings().filter((p) => p.workspace_id !== pairing.workspace_id),
101
+ ...pairingsOf(readNode()).filter((p) => p.workspace_id !== pairing.workspace_id),
71
102
  ];
72
103
  saveNode({ node_key: nodeKey, ...stamped, pairings });
73
104
  }
@@ -86,12 +117,12 @@ function recordPairing(nodeKey, pairing) {
86
117
  * @returns the pairing that was dropped, or null if there was no such pairing.
87
118
  */
88
119
  function clearPairing(workspaceId) {
89
- const record = loadNode();
120
+ const record = readNode();
90
121
  if (!record) return null;
91
122
  const target = workspaceId || record.workspace_id;
92
123
  if (!target) return null;
93
124
 
94
- const pairings = listPairings();
125
+ const pairings = pairingsOf(record);
95
126
  const dropped = pairings.find((p) => p.workspace_id === target);
96
127
  if (!dropped) return null;
97
128
 
@@ -114,12 +145,11 @@ function inferDeviceType() {
114
145
  return 'server'; // linux is typically a server/VM in this context
115
146
  }
116
147
 
117
- /** Device info sent on redeem/heartbeat. */
118
- function gatherDeviceInfo() {
148
+ /** Device info sent on every heartbeat. Reads and writes nothing on disk. */
149
+ function deviceInfo() {
119
150
  let launcherVersion = '';
120
151
  try { launcherVersion = require('../package.json').version; } catch {}
121
152
  return {
122
- nodeKey: getOrCreateNodeKey(),
123
153
  hostname: os.hostname(),
124
154
  os: os.platform(),
125
155
  deviceType: inferDeviceType(),
@@ -127,7 +157,13 @@ function gatherDeviceInfo() {
127
157
  };
128
158
  }
129
159
 
160
+ /** Device info sent on redeem, with the device key (minted on first pairing). */
161
+ function gatherDeviceInfo() {
162
+ return { nodeKey: getOrCreateNodeKey(), ...deviceInfo() };
163
+ }
164
+
130
165
  module.exports = {
166
+ readNode,
131
167
  loadNode,
132
168
  saveNode,
133
169
  listPairings,
@@ -135,6 +171,7 @@ module.exports = {
135
171
  clearPairing,
136
172
  getOrCreateNodeKey,
137
173
  inferDeviceType,
174
+ deviceInfo,
138
175
  gatherDeviceInfo,
139
176
  NODE_FILE,
140
177
  NODE_DIR,