@openagents-org/agent-launcher 0.2.164 → 0.2.166

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.164",
3
+ "version": "0.2.166",
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
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ // Before ./index so the daemon and every `agn` subprocess inherit the same
4
+ // no-stray-console-window default on Windows. See win-console.js.
5
+ require('./win-console').installWindowsHideDefault();
6
+
3
7
  const { AgentConnector, Daemon } = require('./index');
4
8
  const { hasCredentialMetadata, formatAuthGuidance } = require('./auth-guidance');
5
9
 
@@ -436,11 +440,13 @@ async function cmdNode(connector, flags, positional) {
436
440
  print('Connecting this device to your workspace...');
437
441
  try {
438
442
  const res = await connector.redeemNodePairingCode(code, info);
439
- nodeCfg.saveNode({
443
+ // recordPairing, not saveNode: this device can be a node in several
444
+ // workspaces at once, and a bare save would drop the others' pairings.
445
+ nodeCfg.recordPairing(info.nodeKey, {
440
446
  node_id: res.nodeId,
441
- node_key: info.nodeKey,
442
447
  workspace_id: res.workspaceId,
443
448
  workspace_slug: res.workspaceSlug,
449
+ workspace_name: res.workspaceName,
444
450
  endpoint: connector.workspace.endpoint,
445
451
  token: res.token,
446
452
  });
@@ -480,12 +486,14 @@ async function cmdNode(connector, flags, positional) {
480
486
  }
481
487
 
482
488
  if (sub === 'status') {
483
- const n = nodeCfg.loadNode();
484
- if (n && n.node_id) {
485
- print(`Node connected: ${n.node_id}`);
486
- print(` Workspace: ${n.workspace_slug || n.workspace_id}`);
487
- } else {
489
+ const pairings = nodeCfg.listPairings().filter((p) => p.node_id);
490
+ if (!pairings.length) {
488
491
  print('No node connected. Run: agn node connect <pairing-code>');
492
+ return;
493
+ }
494
+ print(`Node connected to ${pairings.length} workspace(s):`);
495
+ for (const p of pairings) {
496
+ print(` ${p.workspace_slug || p.workspace_id} — node ${p.node_id}`);
489
497
  }
490
498
  return;
491
499
  }
package/src/daemon.js CHANGED
@@ -31,7 +31,7 @@ class Daemon {
31
31
  this._statusInterval = null;
32
32
  this._cmdInterval = null;
33
33
  this._nodeHeartbeatInterval = null; // device-level heartbeat (connect-a-node)
34
- this._nodeClient = null; // lazily-created WorkspaceClient for the node
34
+ this._nodeClients = new Map(); // workspace_id -> WorkspaceClient, one per pairing
35
35
  this._runningCommands = new Set(); // node-command ids currently executing
36
36
  this._runtimes = []; // detected agent runtimes for the node view
37
37
  this._runtimesInterval = null;
@@ -39,34 +39,78 @@ class Daemon {
39
39
  }
40
40
 
41
41
  /**
42
- * Heartbeat this device (node) to its workspace, independent of any agent, so
43
- * the workspace's "connected devices" view stays live even with zero agents.
44
- * Best-effort: node liveness is non-critical to agent operation.
42
+ * Heartbeat this device (node) to EVERY workspace it is paired to,
43
+ * independent of any agent, so each workspace's "connected devices" view
44
+ * stays live even with zero agents there.
45
+ *
46
+ * One device legitimately belongs to several workspaces at once — the node
47
+ * row is per (workspace, device) — so this iterates the pairing list rather
48
+ * than the single top-level record. Each pairing gets its own roster, scoped
49
+ * to that workspace's agents. Best-effort: node liveness is non-critical to
50
+ * agent operation, and one workspace being unreachable must not stop the
51
+ * others from reporting, so the pairings are heartbeated concurrently and
52
+ * failures are contained per pairing.
45
53
  */
46
54
  async _nodeHeartbeat() {
55
+ const nodeCfg = require('./node-config');
56
+ const pairings = nodeCfg.listPairings().filter((p) => p && p.node_id && p.token);
57
+ if (!pairings.length) return; // no node connected
58
+ const info = { ...nodeCfg.gatherDeviceInfo(), runtimes: this._runtimes, fs: this._buildFs() };
59
+ await Promise.all(pairings.map((p) => this._nodeHeartbeatOne(nodeCfg, p, info)));
60
+ }
61
+
62
+ /** The WorkspaceClient for one pairing, created on first use and reused. */
63
+ _nodeClientFor(n) {
64
+ let client = this._nodeClients.get(n.workspace_id);
65
+ if (!client) {
66
+ client = new WorkspaceClient(n.endpoint);
67
+ this._nodeClients.set(n.workspace_id, client);
68
+ }
69
+ return client;
70
+ }
71
+
72
+ /** One pairing's heartbeat. Never throws — see _nodeHeartbeat. */
73
+ async _nodeHeartbeatOne(nodeCfg, n, info) {
74
+ let resp;
47
75
  try {
48
- const nodeCfg = require('./node-config');
49
- const n = nodeCfg.loadNode();
50
- if (!n || !n.node_id || !n.token) return; // no node connected
51
- if (!this._nodeClient) {
52
- this._nodeClient = new WorkspaceClient(n.endpoint);
76
+ resp = await this._nodeClientFor(n).nodeHeartbeat(n.node_id, n.token, { ...info, agents: this._buildRoster(n) });
77
+ } catch (err) {
78
+ // A 404 is the workspace's definitive word that this node (or that whole
79
+ // workspace) no longer exists — an owner unpaired the device. Nothing
80
+ // local can revive it, so forget THAT pairing: keep the device key for a
81
+ // future re-pair, and leave every other workspace's pairing alone.
82
+ // Without this the launcher kept showing "connected" long after a remote
83
+ // removal, since node.json was only ever reconciled by the UI on demand.
84
+ // Transient failures (timeouts, 5xx, auth blips) say nothing about the
85
+ // row's existence, so those are swallowed and retried next tick.
86
+ if (err && err.status === 404) {
87
+ try { nodeCfg.clearPairing(n.workspace_id); } catch {}
88
+ this._nodeClients.delete(n.workspace_id);
89
+ this._log(`node ${n.node_id} no longer recognized by workspace ${n.workspace_slug || n.workspace_id} — pairing cleared`);
53
90
  }
54
- const info = { ...nodeCfg.gatherDeviceInfo(), agents: this._buildRoster(), runtimes: this._runtimes, fs: this._buildFs() };
55
- const resp = await this._nodeClient.nodeHeartbeat(n.node_id, n.token, info);
56
- // The heartbeat response is our push channel: run any queued remote
57
- // agent-management commands the workspace enqueued for this node. Fire
58
- // them off without blocking the heartbeat loop (an install can take
59
- // minutes — awaiting here would stall liveness and stack up commands).
60
- const commands = (resp && resp.commands) || [];
61
- for (const cmd of commands) {
62
- const id = cmd.commandId;
63
- if (!id || this._runningCommands.has(id)) continue;
64
- this._runningCommands.add(id);
65
- this._runNodeCommand(n, cmd).finally(() => this._runningCommands.delete(id));
66
- }
67
- } catch {
68
- // ignore — will retry next interval
91
+ return; // nothing more to do for this pairing this tick
69
92
  }
93
+ // The heartbeat response is our push channel: run any queued remote
94
+ // agent-management commands the workspace enqueued for this node. Fire
95
+ // them off without blocking the heartbeat loop (an install can take
96
+ // minutes — awaiting here would stall liveness and stack up commands).
97
+ const commands = (resp && resp.commands) || [];
98
+ for (const cmd of commands) {
99
+ const id = cmd.commandId;
100
+ if (!id || this._runningCommands.has(id)) continue;
101
+ this._runningCommands.add(id);
102
+ this._runNodeCommand(n, cmd).finally(() => this._runningCommands.delete(id));
103
+ }
104
+ }
105
+
106
+ /**
107
+ * True if agent `a` is bound to the node's currently-connected workspace.
108
+ * A missing network never matches (guards against undefined === undefined
109
+ * leaking local-only agents when a node field is also unset).
110
+ */
111
+ _agentOnNodeWorkspace(a, node) {
112
+ if (!a || !a.network || !node) return false;
113
+ return a.network === node.workspace_slug || a.network === node.workspace_id;
70
114
  }
71
115
 
72
116
  /**
@@ -74,11 +118,18 @@ class Daemon {
74
118
  * from config (the source of truth for what's configured) and augmented with
75
119
  * live process state, so a removed agent drops off immediately rather than
76
120
  * lingering as a stale 'stopped' process entry.
121
+ *
122
+ * SECURITY: scoped to the node's currently-connected workspace. An agent
123
+ * connected to a DIFFERENT workspace (or a local-only agent with no network)
124
+ * must never leak into — or be controllable from — a workspace it was never
125
+ * added to. Each agent belongs to exactly one workspace; this node reports
126
+ * only the agents that belong to the one it's paired with right now.
77
127
  */
78
- _buildRoster() {
128
+ _buildRoster(node) {
79
129
  const roster = [];
80
130
  try {
81
131
  for (const a of this.config.getAgents()) {
132
+ if (!this._agentOnNodeWorkspace(a, node)) continue;
82
133
  const proc = this._processes[a.name];
83
134
  // Model: per-agent env override, else the type-level saved model. Working
84
135
  // dir: the agent's configured path. Both power the workspace agent cards.
@@ -172,7 +223,18 @@ class Daemon {
172
223
  let ok = false;
173
224
  let message = '';
174
225
  let data = null;
226
+ // SECURITY: a workspace may only act on agents bound to it. Reject commands
227
+ // that target an existing agent connected to a different workspace, so a
228
+ // newly-paired workspace can't start/stop/remove/reconfigure agents that
229
+ // belong to another one (they surface in no roster of ours either).
230
+ const AGENT_SCOPED = new Set(['start_agent', 'stop_agent', 'remove_agent', 'configure_agent']);
175
231
  try {
232
+ if (AGENT_SCOPED.has(action)) {
233
+ const existing = this.config.getAgent(name);
234
+ if (!existing || !this._agentOnNodeWorkspace(existing, n)) {
235
+ throw new Error(`Agent '${name}' is not managed by this workspace`);
236
+ }
237
+ }
176
238
  if (action === 'create_agent') {
177
239
  const type = (args.type || '').trim();
178
240
  // Working directory: use the one the user picked, else a managed folder
@@ -245,7 +307,9 @@ class Daemon {
245
307
  message = e.message || String(e);
246
308
  }
247
309
  try {
248
- await this._nodeClient.nodeCommandResult(cmd.commandId, n.token, { ok, message, data });
310
+ // Report back to the workspace that issued the command — with several
311
+ // pairings live, the result must not go out over another one's client.
312
+ await this._nodeClientFor(n).nodeCommandResult(cmd.commandId, n.token, { ok, message, data });
249
313
  } catch {
250
314
  // best-effort; the command stays 'running' if we can't report back
251
315
  }
@@ -265,6 +329,10 @@ class Daemon {
265
329
  // Run from home so a created agent's default working dir is sensible
266
330
  // (not the ~/.openagents config dir).
267
331
  cwd: os.homedir(),
332
+ // The daemon has no console of its own (it is spawned DETACHED), so a
333
+ // child without this gets a fresh console window — the blank black box
334
+ // that used to appear every time _refreshRuntimes() ran.
335
+ windowsHide: true,
268
336
  });
269
337
  let stdout = '';
270
338
  let stderr = '';
@@ -921,6 +989,9 @@ class Daemon {
921
989
  stdio: ['ignore', 'pipe', 'pipe'],
922
990
  env: getEnhancedEnv(opts.env),
923
991
  cwd: opts.cwd,
992
+ // Windows: cmd.exe would otherwise open a console window per agent (the
993
+ // daemon has none to inherit) and leave it up for the agent's lifetime.
994
+ windowsHide: true,
924
995
  };
925
996
 
926
997
  if (IS_WINDOWS) {
package/src/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ // Must run before anything captures a child_process reference: on Windows it
4
+ // keeps every spawn from popping a stray console window. See win-console.js.
5
+ require('./win-console').installWindowsHideDefault();
6
+
3
7
  const { Config } = require('./config');
4
8
  const { EnvManager } = require('./env');
5
9
  const { Registry } = require('./registry');
@@ -40,6 +40,72 @@ function getOrCreateNodeKey() {
40
40
  return key;
41
41
  }
42
42
 
43
+ /**
44
+ * Every workspace this device is paired to, most recent first.
45
+ *
46
+ * All of them are live: the server-side node row is keyed per (workspace,
47
+ * node_key), so one device holds a membership in each workspace it paired with
48
+ * and the daemon heartbeats every one. Records written before `pairings`
49
+ * existed carry a single top-level pairing, which is reconstructed here.
50
+ */
51
+ 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] : [];
56
+ }
57
+
58
+ /**
59
+ * Add a pairing, or refresh it if this workspace is already paired.
60
+ *
61
+ * Additive on purpose: pairing a second workspace must NOT take the device away
62
+ * from the ones it already belongs to (each issues its own node row). The newest
63
+ * pairing is mirrored at the top level, which is what a daemon predating
64
+ * multi-pairing reads. Mirrors the launcher's writer (`node-pairing.ts`).
65
+ */
66
+ function recordPairing(nodeKey, pairing) {
67
+ const stamped = { ...pairing, paired_at: new Date().toISOString() };
68
+ const pairings = [
69
+ stamped,
70
+ ...listPairings().filter((p) => p.workspace_id !== pairing.workspace_id),
71
+ ];
72
+ saveNode({ node_key: nodeKey, ...stamped, pairings });
73
+ }
74
+
75
+ /**
76
+ * Forget one workspace's pairing — that workspace no longer recognizes this node.
77
+ *
78
+ * Keeps `node_key` (so a re-pair reuses the same device id) and drops just the
79
+ * one entry: the server-side node row for THAT workspace is gone and can only
80
+ * be replaced by a fresh code, while every other pairing is still good. The top
81
+ * level is re-pointed at whichever pairing is now first, since that is what a
82
+ * daemon predating multi-pairing reads. Mirrors the launcher's writer
83
+ * (`node-pairing.ts`) so the two stay schema-compatible.
84
+ *
85
+ * @param workspaceId which pairing to drop; defaults to the top-level one.
86
+ * @returns the pairing that was dropped, or null if there was no such pairing.
87
+ */
88
+ function clearPairing(workspaceId) {
89
+ const record = loadNode();
90
+ if (!record) return null;
91
+ const target = workspaceId || record.workspace_id;
92
+ if (!target) return null;
93
+
94
+ const pairings = listPairings();
95
+ const dropped = pairings.find((p) => p.workspace_id === target);
96
+ if (!dropped) return null;
97
+
98
+ const remaining = pairings.filter((p) => p.workspace_id !== target);
99
+ saveNode({ node_key: record.node_key, ...remaining[0], pairings: remaining });
100
+ return {
101
+ node_id: dropped.node_id,
102
+ workspace_id: dropped.workspace_id,
103
+ workspace_slug: dropped.workspace_slug,
104
+ workspace_name: dropped.workspace_name,
105
+ endpoint: dropped.endpoint,
106
+ };
107
+ }
108
+
43
109
  /** Best-effort device type from the platform (user/UI can override). */
44
110
  function inferDeviceType() {
45
111
  const p = os.platform();
@@ -64,6 +130,9 @@ function gatherDeviceInfo() {
64
130
  module.exports = {
65
131
  loadNode,
66
132
  saveNode,
133
+ listPairings,
134
+ recordPairing,
135
+ clearPairing,
67
136
  getOrCreateNodeKey,
68
137
  inferDeviceType,
69
138
  gatherDeviceInfo,
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Stop Windows from popping a console window for every child process we spawn.
5
+ *
6
+ * Neither process that runs this code owns a console: the daemon is spawned
7
+ * DETACHED (`agn up --foreground`), and the launcher's Electron main process is
8
+ * a GUI app. On Windows, when a console-less process launches a console
9
+ * executable *without* CREATE_NO_WINDOW, the OS allocates a brand-new console
10
+ * for the child — an empty black cmd/Windows-Terminal window that pops to the
11
+ * foreground and steals focus. With stdio piped nothing is ever drawn in it,
12
+ * so what the user sees is a blank window appearing out of nowhere.
13
+ *
14
+ * That is the "命令框 keeps popping up" report: `_refreshRuntimes()` shells out
15
+ * to `agn runtimes --json` every two minutes, and the probe it runs keeps the
16
+ * window on screen for as long as it takes to check every installed CLI.
17
+ *
18
+ * Node exposes CREATE_NO_WINDOW as the `windowsHide` spawn option and most call
19
+ * sites pass it — but there are ~60 of them across the adapters and a single
20
+ * miss brings the window back. So the default lives here, applied once at the
21
+ * process entry points (src/index.js, src/cli.js) before any module has
22
+ * captured a reference to child_process.
23
+ *
24
+ * Two things this deliberately does NOT do:
25
+ * - override an explicit `windowsHide: false` — the launcher opens a real
26
+ * terminal for CLI sign-in that way, and that window is the point; and
27
+ * - break interactive output: libuv only applies CREATE_NO_WINDOW when no
28
+ * stdio handle is inherited, so `agn` run from a terminal (stdio 'inherit')
29
+ * is unaffected by this and still prints where the user can see it.
30
+ */
31
+
32
+ const childProcess = require('child_process');
33
+
34
+ const PATCHED = Symbol.for('openagents.windowsHideDefault');
35
+ const WRAPPED = ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync'];
36
+
37
+ /**
38
+ * Return a copy of `args` whose options object carries `windowsHide: true`.
39
+ *
40
+ * Every child_process signature ends with `[options][, callback]`, so the
41
+ * options object — if there is one — is the last non-function argument. The
42
+ * caller's object is copied rather than mutated: callers reuse option objects
43
+ * and must not see ours leak into their state.
44
+ */
45
+ function withWindowsHide(args) {
46
+ const out = args.slice();
47
+ const cbIdx = typeof out[out.length - 1] === 'function' ? out.length - 1 : -1;
48
+ const optIdx = cbIdx === -1 ? out.length - 1 : cbIdx - 1;
49
+ const opts = optIdx >= 0 ? out[optIdx] : undefined;
50
+
51
+ if (opts && typeof opts === 'object' && !Array.isArray(opts)) {
52
+ if ('windowsHide' in opts) return args; // explicit wins, either way
53
+ out[optIdx] = { ...opts, windowsHide: true };
54
+ return out;
55
+ }
56
+ // No options argument at all — insert one ahead of any callback.
57
+ out.splice(cbIdx === -1 ? out.length : cbIdx, 0, { windowsHide: true });
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Default `windowsHide: true` for this process's child_process calls.
63
+ * No-op off Windows, and idempotent.
64
+ */
65
+ function installWindowsHideDefault() {
66
+ if (process.platform !== 'win32') return;
67
+ if (childProcess[PATCHED]) return;
68
+
69
+ for (const name of WRAPPED) {
70
+ const original = childProcess[name];
71
+ if (typeof original !== 'function') continue;
72
+ const wrapper = function (...args) {
73
+ return original.apply(this, withWindowsHide(args));
74
+ };
75
+ // Keep util.promisify(exec) and friends working.
76
+ for (const sym of Object.getOwnPropertySymbols(original)) {
77
+ wrapper[sym] = original[sym];
78
+ }
79
+ Object.defineProperty(wrapper, 'name', { value: name });
80
+ childProcess[name] = wrapper;
81
+ }
82
+ childProcess[PATCHED] = true;
83
+ }
84
+
85
+ module.exports = { installWindowsHideDefault, withWindowsHide };
@@ -969,6 +969,10 @@ class WorkspaceClient {
969
969
  // Preserve structured error details (error_code, hint,
970
970
  // quota occupancy, ...) so tool handlers can surface them.
971
971
  if (parsed.data && typeof parsed.data === 'object') err.data = parsed.data;
972
+ // The HTTP status lets callers tell a definitive rejection
973
+ // (e.g. 404 "not found") from a transient one (5xx/timeout);
974
+ // the node heartbeat uses it to clear a removed pairing.
975
+ err.status = res.statusCode;
972
976
  reject(err);
973
977
  }
974
978
  } else {