@openagents-org/agent-launcher 0.2.180 → 0.2.181

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.180",
3
+ "version": "0.2.181",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -168,17 +168,50 @@ function parseLine(line) {
168
168
  * When real samples are available, extend THIS table only.
169
169
  */
170
170
  const EVENT_KIND_BY_TYPE = {
171
- // session lifecycle / identity (kind only mapped when an id is also present)
171
+ // ── CAPTURED from a real authenticated run (CLI v1.0.83) ────────────────
172
+ // Every name in this block was read off a live JSONL stream, not inferred.
173
+ // The payload of each sits under `data` (see the `data` unwrap in
174
+ // classifyEvent) — except `result`, which is flat.
175
+ //
176
+ // The full type list observed in one turn: assistant.turn_start,
177
+ // assistant.reasoning_delta, assistant.reasoning, assistant.message_start,
178
+ // assistant.message_delta, assistant.turn_end, assistant.idle,
179
+ // model.call_start, model.call_finished, session.auto_mode_resolved,
180
+ // session.usage_checkpoint, session.tools_updated, session.skills_loaded,
181
+ // session.mcp_servers_loaded, session.mcp_server_status_changed,
182
+ // session.custom_agents_updated, user.message, result.
183
+ //
184
+ // The ones with no entry here are deliberate: they carry no content the
185
+ // workspace shows (lifecycle bookkeeping, or `user.message`, which is our
186
+ // own prompt echoed back). They degrade to `unknown`, which is logged and
187
+ // ignored — mapping them would put noise in the chat.
188
+ 'assistant.message_delta': 'text_delta', // data.deltaContent — THE answer
189
+ 'assistant.reasoning': 'reasoning', // data.content — ONE whole block
190
+ result: 'done', // flat: sessionId, exitCode, usage
191
+ // NOT mapped on purpose: `assistant.reasoning_delta`. Copilot streams at
192
+ // TOKEN granularity — 81 deltas for one short thought in the captured turn —
193
+ // and every adapter here narrates progress in whole blocks (claude sends
194
+ // `block.text`, codex `item.text`). Mapping the deltas posted one chat
195
+ // message per token: a column of "How" / "can" / "I" / "help" / "?" in the
196
+ // workspace. `assistant.reasoning` carries the same content as a single
197
+ // block afterwards, so that is what gets narrated.
198
+ 'session.usage_checkpoint': 'usage',
199
+ 'session.auto_mode_resolved': 'usage', // data.chosenModel under Auto
200
+ 'model.call_finished': 'usage',
201
+
202
+ // ── INFERRED — no live sample yet ───────────────────────────────────────
203
+ // The captured turn was a plain greeting, so no tool/file/shell/permission
204
+ // event appeared in it. The names below predate that capture and are kept
205
+ // as a net: they cost nothing (the real names above are matched first) and
206
+ // an unrecognized event is logged, never rendered. Replace them the moment
207
+ // a tool-using turn is captured — do not add more guesses.
172
208
  session: 'session', 'session.created': 'session', 'session.started': 'session',
173
209
  session_started: 'session', thread: 'session', 'thread.started': 'session',
174
- // streaming assistant text (qualified names only — no bare 'delta'/'token')
175
210
  'text.delta': 'text_delta', text_delta: 'text_delta',
176
211
  'message.delta': 'text_delta', 'assistant.delta': 'text_delta',
177
212
  content_block_delta: 'text_delta',
178
- // final assistant text (qualified/explicit names only — no bare 'message')
179
213
  text: 'text', 'message.completed': 'text', 'assistant.message': 'text',
180
214
  assistant: 'text', completion: 'text', agent_message: 'text',
181
- // reasoning / status narration
182
215
  reasoning: 'reasoning', thinking: 'reasoning', thought: 'reasoning',
183
216
  status: 'reasoning', progress: 'reasoning',
184
217
  // tool calls
@@ -250,7 +283,14 @@ function classifyEvent(obj) {
250
283
  const itemKind = itemType ? EVENT_KIND_BY_TYPE[itemType.toLowerCase()] : undefined;
251
284
  if (itemKind) kind = itemKind;
252
285
  }
253
- const src = item || obj;
286
+ // Copilot puts every event's payload under `data`, with the envelope
287
+ // (type/id/timestamp/parentId) on the outside — so reading fields off the
288
+ // top level found nothing and the answer came back empty. `result` is the
289
+ // one exception: it is flat, and falls through to `obj` here.
290
+ const data = (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data))
291
+ ? obj.data
292
+ : null;
293
+ const src = item || data || obj;
254
294
 
255
295
  switch (kind) {
256
296
  case 'session': {
@@ -261,7 +301,9 @@ function classifyEvent(obj) {
261
301
  return { kind: 'session', sessionId };
262
302
  }
263
303
  case 'text_delta': {
264
- const text = _coerceText(src.delta) ?? _coerceText(src.text) ?? _coerceText(src.content) ?? _coerceText(src);
304
+ // `deltaContent` is Copilot's own field name, verified on v1.0.83.
305
+ const text = _coerceText(src.deltaContent) ?? _coerceText(src.delta)
306
+ ?? _coerceText(src.text) ?? _coerceText(src.content) ?? _coerceText(src);
265
307
  return { kind: 'text_delta', text: text || '' };
266
308
  }
267
309
  case 'text': {
@@ -269,7 +311,8 @@ function classifyEvent(obj) {
269
311
  return { kind: 'text', text: text || '' };
270
312
  }
271
313
  case 'reasoning': {
272
- const text = _coerceText(src.text) ?? _coerceText(src.reasoning) ?? _coerceText(src.message)
314
+ const text = _coerceText(src.deltaContent) ?? _coerceText(src.text)
315
+ ?? _coerceText(src.reasoning) ?? _coerceText(src.message)
273
316
  ?? _coerceText(src.content) ?? _firstString(src, ['status', 'label', 'title']) ?? '';
274
317
  return { kind: 'reasoning', text };
275
318
  }
@@ -313,7 +356,14 @@ function classifyEvent(obj) {
313
356
  }
314
357
  case 'done': {
315
358
  const status = _firstString(src, ['status', 'reason', 'result']) || 'completed';
316
- return { kind: 'done', status };
359
+ // The `result` frame is also where the real session id finally shows up
360
+ // — there is no session event earlier in the stream. Carrying it here is
361
+ // what lets the next turn resume instead of starting over. Read from the
362
+ // top level too: `result` is flat, so `src` is already `obj`, but an
363
+ // `item`-wrapped variant would not be.
364
+ const sessionId = _firstString(src, ['sessionId', 'session_id'])
365
+ ?? _firstString(obj, ['sessionId', 'session_id']);
366
+ return sessionId ? { kind: 'done', status, sessionId } : { kind: 'done', status };
317
367
  }
318
368
  case 'error': {
319
369
  const err = (src.error && typeof src.error === 'object') ? src.error : src;
@@ -606,10 +606,14 @@ class CopilotAdapter extends BaseAdapter {
606
606
  }
607
607
  break;
608
608
  case 'text_delta':
609
- // Streamed interim text is shown live as `thinking`; the final
610
- // `text` event (if any) is the authoritative answer.
609
+ // Accumulate only — never narrate. These deltas ARE the final
610
+ // answer being streamed a token at a time (Copilot's
611
+ // `assistant.message_delta`), and the answer is posted in full
612
+ // when the turn ends. Sending each one as `thinking` published the
613
+ // reply twice: once as a column of one-word messages, then again
614
+ // whole.
611
615
  if (sawToolSinceText) { finalParts.length = 0; deltaBuf = ''; sawToolSinceText = false; }
612
- if (ev.text) { deltaBuf += ev.text; try { await this.sendThinking(channel, ev.text); } catch {} }
616
+ if (ev.text) deltaBuf += ev.text;
613
617
  break;
614
618
  case 'text':
615
619
  // A complete message supersedes the deltas streamed for this block.
@@ -655,6 +659,13 @@ class CopilotAdapter extends BaseAdapter {
655
659
  errorEvent = errorEvent || { message: ev.message, code: ev.code };
656
660
  break;
657
661
  case 'done':
662
+ // Copilot reports its session id only in the terminal `result`
663
+ // frame — no session event precedes it — so this is the one place
664
+ // a resumable id can be captured.
665
+ if (ev.sessionId && this._channelSessions[channel] !== ev.sessionId) {
666
+ this._channelSessions[channel] = ev.sessionId;
667
+ this._saveSessions();
668
+ }
658
669
  break;
659
670
  case 'unknown':
660
671
  // Preserve a redacted diagnostic; never crash the task.
package/src/daemon.js CHANGED
@@ -43,6 +43,7 @@ class Daemon {
43
43
  this._statusInterval = null;
44
44
  this._cmdInterval = null;
45
45
  this._nodeHeartbeatInterval = null; // device-level heartbeat (connect-a-node)
46
+ this._displayNameInterval = null; // pulls workspace-side renames back
46
47
  this._nodeClients = new Map(); // workspace_id -> WorkspaceClient, one per pairing
47
48
  this._runningCommands = new Set(); // node-command ids currently executing
48
49
  this._runtimes = []; // detected agent runtimes for the node view
@@ -225,6 +226,50 @@ class Daemon {
225
226
  }
226
227
  }
227
228
 
229
+ /**
230
+ * Reflect workspace-side renames back onto this device.
231
+ *
232
+ * The workspace is treated as authoritative for the label, which needs no
233
+ * timestamps to be correct: a rename started on the device writes to the
234
+ * workspace FIRST and only then to local config (see
235
+ * AgentConnector.setAgentDisplayName), so by the time this reads, the
236
+ * workspace already holds whatever the device just set. Anything different
237
+ * from local therefore came from the workspace and should win.
238
+ *
239
+ * Never throws — a workspace that is unreachable simply leaves the labels
240
+ * as they are until the next pass.
241
+ */
242
+ async _syncDisplayNames() {
243
+ let pairings = [];
244
+ try {
245
+ pairings = require('./node-config').listPairings();
246
+ } catch { return; }
247
+
248
+ for (const n of pairings) {
249
+ if (!n.workspace_id || !n.token) continue;
250
+ let members = [];
251
+ try {
252
+ members = await this._nodeClientFor(n).getAgents(n.workspace_id, n.token);
253
+ } catch { continue; }
254
+
255
+ const byName = new Map(members.map((m) => [m.agentName, m]));
256
+ for (const a of this.config.getAgents()) {
257
+ // Scoped to this pairing's workspace, like _buildRoster: an agent in
258
+ // another workspace must never be relabelled from this one.
259
+ if (!this._agentOnNodeWorkspace(a, n)) continue;
260
+ const remote = byName.get(a.name);
261
+ if (!remote) continue;
262
+ const local = a.display_name || null;
263
+ const wanted = remote.displayName || null;
264
+ if (local === wanted) continue;
265
+ try {
266
+ this.config.updateAgent(a.name, { display_name: wanted || undefined });
267
+ this._log(`display name for ${a.name} updated from workspace`);
268
+ } catch {}
269
+ }
270
+ }
271
+ }
272
+
228
273
  /**
229
274
  * True if agent `a` is bound to the node's currently-connected workspace.
230
275
  * A missing network never matches (guards against undefined === undefined
@@ -608,6 +653,15 @@ async _runNodeCommand(n, cmd) {
608
653
  15000,
609
654
  );
610
655
 
656
+ // Pull display-name renames made in the workspace back onto the device, so
657
+ // the two sides agree whichever one the user typed into. Separate from the
658
+ // 10s heartbeat on purpose: a label changes rarely and this is an extra
659
+ // request per paired workspace, so it runs a sixth as often.
660
+ this._displayNameInterval = setInterval(
661
+ () => this._syncDisplayNames(),
662
+ 60000,
663
+ );
664
+
611
665
  // Detect installed/logged-in agent runtimes for the Add-agent gallery. Runs
612
666
  // in a child process (off the event loop), refreshed periodically.
613
667
  this._refreshRuntimes();
@@ -658,6 +712,7 @@ async _runNodeCommand(n, cmd) {
658
712
  if (this._nodeHeartbeatInterval) clearInterval(this._nodeHeartbeatInterval);
659
713
  if (this._credReconcileInterval) clearInterval(this._credReconcileInterval);
660
714
  if (this._runtimesInterval) clearInterval(this._runtimesInterval);
715
+ if (this._displayNameInterval) clearInterval(this._displayNameInterval);
661
716
  if (this._probeStartupTimer) clearTimeout(this._probeStartupTimer);
662
717
  if (this._probeInterval) clearInterval(this._probeInterval);
663
718
  if (this._configWatcher) { try { this._configWatcher.close(); } catch {} }
package/src/index.js CHANGED
@@ -84,6 +84,10 @@ class AgentConnector {
84
84
  const network = networks.find((n) => n.slug === a.network || n.id === a.network);
85
85
  return {
86
86
  name: a.name,
87
+ // The label to show. `name` stays the identity — callers that key on
88
+ // it (sessions, working dirs, workspace membership) must keep using
89
+ // `name`, never this.
90
+ displayName: a.display_name || null,
87
91
  type,
88
92
  role: a.role || 'worker',
89
93
  network: a.network || null,
@@ -100,6 +104,95 @@ class AgentConnector {
100
104
  return { success: true };
101
105
  }
102
106
 
107
+ /**
108
+ * The workspace an agent is bound to, as a ready-to-use client — or null
109
+ * when it is local-only or its network is no longer configured.
110
+ *
111
+ * Each network carries its own endpoint (a self-hosted workspace is not the
112
+ * official one), so the client is built per call rather than reusing
113
+ * `this.workspace`, the same way removeWorkspace does.
114
+ */
115
+ _workspaceClientFor(agentName) {
116
+ const agent = this.config.getAgent(agentName);
117
+ if (!agent || !agent.network) return null;
118
+ const network = this.config
119
+ .getNetworks()
120
+ .find((n) => n.slug === agent.network || n.id === agent.network);
121
+ if (!network || !network.id) return null;
122
+ const endpoint = network.endpoint || (this.workspace && this.workspace.endpoint);
123
+ const { WorkspaceClient } = require('./workspace-client');
124
+ return { client: new WorkspaceClient(endpoint), network };
125
+ }
126
+
127
+ /**
128
+ * Set (or clear, with an empty value) an agent's display label.
129
+ *
130
+ * The agent's `name` is its identity — it keys daemon.yaml, the working
131
+ * directory under ~/.openagents/agents, its sessions and its workspace
132
+ * membership — so renaming NEVER touches it. What changes is the label the
133
+ * launcher and the workspace show, which is precisely what the workspace
134
+ * already models as `display_name` next to `agent_name`.
135
+ *
136
+ * When the agent is in a workspace the new label is pushed there too, so the
137
+ * two sides agree. A workspace that rejects it (a name another member
138
+ * already answers to) fails the whole call rather than leaving the label
139
+ * changed on one side only.
140
+ */
141
+ async setAgentDisplayName(name, displayName) {
142
+ const agent = this.config.getAgent(name);
143
+ if (!agent) throw new Error(`Agent '${name}' not found`);
144
+ const label = (displayName || '').trim();
145
+
146
+ const bound = this._workspaceClientFor(name);
147
+ if (bound) {
148
+ await bound.client.setMemberDisplayName(
149
+ bound.network.id,
150
+ bound.network.token || '',
151
+ name,
152
+ label,
153
+ );
154
+ }
155
+ // Local write comes second: if the workspace refused the label, nothing
156
+ // here has changed and the two sides are still in agreement.
157
+ this.config.updateAgent(name, { display_name: label || undefined });
158
+ return { success: true, displayName: label || null };
159
+ }
160
+
161
+ /**
162
+ * Drop an agent's member row from the workspace it joined.
163
+ *
164
+ * Deliberately SEPARATE from removeAgent rather than a flag on it. Removing
165
+ * an agent is a synchronous local edit that `agn remove` and the TUI call
166
+ * without awaiting, inside a try/catch — making it async would turn a thrown
167
+ * error into an unhandled rejection those callers can no longer see. So the
168
+ * network half lives here, and a caller that wants both awaits this first:
169
+ * failing to reach the workspace then leaves the agent intact locally, which
170
+ * is the recoverable order.
171
+ *
172
+ * A local-only agent is a no-op success — there is nothing over there.
173
+ */
174
+ async removeAgentFromWorkspace(name) {
175
+ const bound = this._workspaceClientFor(name);
176
+ if (!bound) return { success: true, skipped: true };
177
+ try {
178
+ await bound.client.removeMember(
179
+ bound.network.id,
180
+ bound.network.token || '',
181
+ name,
182
+ );
183
+ } catch (e) {
184
+ // 404 is the goal state, reached by someone else: the member row is
185
+ // already gone, or the whole workspace is. Treating it as a failure
186
+ // would strand the agent on this device — removal happens local-last, so
187
+ // an error here means it cannot be removed at all. Anything else (a
188
+ // network blip, an auth failure) is real and must stop the removal so
189
+ // the user can retry rather than lose track of a live membership.
190
+ if (!e || e.status !== 404) throw e;
191
+ return { success: true, alreadyGone: true };
192
+ }
193
+ return { success: true };
194
+ }
195
+
103
196
  removeAgent(name) {
104
197
  this.config.removeAgent(name);
105
198
  return { success: true };
@@ -193,11 +286,38 @@ class AgentConnector {
193
286
  return { success: true };
194
287
  }
195
288
 
289
+ /**
290
+ * Local half of leaving a workspace: forget the binding, keep the agent.
291
+ *
292
+ * Synchronous, and deliberately still local-only — `agn disconnect` and the
293
+ * TUI call it without awaiting, inside a try/catch, so an async version
294
+ * would turn a throw into an unhandled rejection they can no longer see.
295
+ * Callers that want the membership dropped too use leaveWorkspace below.
296
+ */
196
297
  disconnectWorkspace(agentName) {
197
298
  this.config.setAgentNetwork(agentName, null);
198
299
  return { success: true };
199
300
  }
200
301
 
302
+ /**
303
+ * Leave a workspace properly: drop the member row over there, and unbind
304
+ * here — the agent itself stays, with its config, credentials and working
305
+ * directory, ready to join another workspace.
306
+ *
307
+ * Leaving only the local half is the bug this replaces. The device stopped
308
+ * showing the agent as connected while the workspace went on listing it as a
309
+ * member, and nothing in the launcher could reach that row any more. The
310
+ * workspace-level "remove a workspace" action learned this same lesson
311
+ * already (see removeWorkspace) — this is the agent-level equivalent.
312
+ *
313
+ * Workspace first, local second: a failure there leaves the agent bound and
314
+ * retryable rather than orphaning a membership.
315
+ */
316
+ async leaveWorkspace(agentName) {
317
+ await this.removeAgentFromWorkspace(agentName);
318
+ return this.disconnectWorkspace(agentName);
319
+ }
320
+
201
321
  async removeWorkspace(slug) {
202
322
  const networks = this.config.getNetworks();
203
323
  const network = networks.find(n => n.slug === slug || n.id === slug);
@@ -84,6 +84,40 @@ class WorkspaceClient {
84
84
  }
85
85
  }
86
86
 
87
+ /**
88
+ * Remove one agent from a workspace via
89
+ * DELETE /v1/workspaces/{workspaceId}/members/{agentName}.
90
+ *
91
+ * Unlike deleteWorkspace above this is NOT best-effort: the caller offered
92
+ * the user a choice ("also remove it from the workspace"), so a failure has
93
+ * to be reported rather than swallowed — silently leaving the member behind
94
+ * is the exact problem this exists to fix.
95
+ */
96
+ async removeMember(workspaceId, token, agentName) {
97
+ await this._delete(
98
+ `/v1/workspaces/${workspaceId}/members/${encodeURIComponent(agentName)}`,
99
+ this._wsHeaders(token),
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Rename one agent's DISPLAY name in a workspace, via
105
+ * PATCH /v1/workspaces/{workspaceId}/members/{agentName}.
106
+ *
107
+ * The agent's identity (`agent_name`) is untouched — this is the label the
108
+ * workspace shows and accepts as an @-mention alias, which is why the server
109
+ * rejects one that clashes with another member. An empty string clears the
110
+ * label there, falling back to `agent_name`, which is exactly what clearing
111
+ * the display name on this side means too.
112
+ */
113
+ async setMemberDisplayName(workspaceId, token, agentName, displayName) {
114
+ return this._patch(
115
+ `/v1/workspaces/${workspaceId}/members/${encodeURIComponent(agentName)}`,
116
+ { display_name: displayName || '' },
117
+ this._wsHeaders(token),
118
+ );
119
+ }
120
+
87
121
  /**
88
122
  * Join a workspace via POST /v1/join.
89
123
  */
@@ -514,6 +548,10 @@ class WorkspaceClient {
514
548
  const agents = (result && result.agents) || [];
515
549
  return agents.map((a) => ({
516
550
  agentName: (a.address || '').replace('openagents:', ''),
551
+ // The workspace's label for this agent, or null when it has none and
552
+ // falls back to agentName. Carried so a rename made in the workspace can
553
+ // be reflected back on the device.
554
+ displayName: a.display_name || null,
517
555
  role: a.role || 'member',
518
556
  status: a.status || 'offline',
519
557
  enabledSkills: a.enabled_skills || null,