@openagents-org/agent-launcher 0.2.193 → 0.2.194

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.
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+ /**
3
+ * Roadmap v1.1 M3/M6 — presence detail + structured agent-to-agent hand-off.
4
+ *
5
+ * workspace_agent_availability is <agent> free / busy / offline / runtime down?
6
+ * workspace_handoff hand work to another agent with request, context,
7
+ * output so far and next owner travelling together
8
+ * workspace_get_handoffs the recent hand-offs in a thread
9
+ *
10
+ * A hand-off is an ordinary `workspace.message.posted` chat message that
11
+ * OPENS with `@<to_agent>` (the backend's deterministic explicit-target path:
12
+ * it routes to exactly that agent and adds it to the thread) and carries the
13
+ * structured record twice — `payload.handoff` for readers of the thread and
14
+ * `metadata.handoff` for routing/analytics — so nothing depends on parsing
15
+ * the markdown back out.
16
+ */
17
+
18
+ const MAX_HANDOFFS = 10;
19
+
20
+ const defs = [
21
+ {
22
+ name: 'workspace_agent_availability',
23
+ description:
24
+ 'Check whether another agent can take work right now. Returns its status, whether it is busy ' +
25
+ '(and in which threads / how many messages are queued), whether the device it runs on is ' +
26
+ 'online, and one reason: available | busy | agent_offline | runtime_offline.',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ agent: { type: 'string', description: 'Agent name (without the openagents: prefix)' },
31
+ },
32
+ required: ['agent'],
33
+ },
34
+ },
35
+ {
36
+ name: 'workspace_handoff',
37
+ description:
38
+ 'Hand a piece of work to another agent in the current thread. Posts "@<to_agent> <request>" with ' +
39
+ 'optional Context / Output so far / Next owner sections so the request, what it needs, what you ' +
40
+ 'already produced and who owns the next step travel together. Only the addressed agent is woken. ' +
41
+ 'Use workspace_agent_availability first if timing matters.',
42
+ inputSchema: {
43
+ type: 'object',
44
+ properties: {
45
+ to_agent: { type: 'string', description: 'Agent to hand the work to (name only)' },
46
+ request: { type: 'string', description: 'What you are asking the agent to do — one or two sentences' },
47
+ context: { type: 'string', description: 'Background the agent needs (inputs, constraints, links, file ids)' },
48
+ output: { type: 'string', description: 'What you have already produced (results, paths, partial work)' },
49
+ next_owner: { type: 'string', description: 'Who owns the next step after this (defaults to to_agent)' },
50
+ },
51
+ required: ['to_agent', 'request'],
52
+ },
53
+ },
54
+ {
55
+ name: 'workspace_get_handoffs',
56
+ description:
57
+ 'List the most recent structured hand-offs in a thread (newest first, max 10): who handed what to ' +
58
+ 'whom, with the context, output and next owner they carried. Defaults to the current thread.',
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ channel: { type: 'string', description: 'Thread name; omit for the current one' },
63
+ limit: { type: 'integer', description: 'How many to return (1-10, default 10)' },
64
+ },
65
+ },
66
+ },
67
+ ];
68
+
69
+ function cleanName(name) {
70
+ return String(name || '').trim().replace(/^@/, '').replace(/^openagents:/, '');
71
+ }
72
+
73
+ function cleanText(v, max) {
74
+ if (v === undefined || v === null) return null;
75
+ const s = String(v).trim();
76
+ if (!s) return null;
77
+ return max && s.length > max ? s.slice(0, max) + '…' : s;
78
+ }
79
+
80
+ /**
81
+ * Build the hand-off record + message content. Pure; exported for tests.
82
+ * Returns { handoff, content } where handoff =
83
+ * { from, to, request, context, output, next_owner }.
84
+ */
85
+ function buildHandoff(from, { to_agent, request, context, output, next_owner } = {}) {
86
+ const to = cleanName(to_agent);
87
+ const req = cleanText(request, 4000);
88
+ if (!to) throw new Error('to_agent is required');
89
+ if (!req) throw new Error('request is required');
90
+ const handoff = {
91
+ from: cleanName(from),
92
+ to,
93
+ request: req,
94
+ context: cleanText(context, 8000),
95
+ output: cleanText(output, 8000),
96
+ next_owner: cleanName(next_owner) || to,
97
+ };
98
+ let content = `@${to} ${req}`;
99
+ if (handoff.context) content += `\n\nContext:\n${handoff.context}`;
100
+ if (handoff.output) content += `\n\nOutput so far:\n${handoff.output}`;
101
+ content += `\n\nNext owner: ${handoff.next_owner}`;
102
+ return { handoff, content };
103
+ }
104
+
105
+ /** The hand-off records found in a list of raw events (newest first). */
106
+ function extractHandoffs(events, limit = MAX_HANDOFFS) {
107
+ const out = [];
108
+ for (const e of events || []) {
109
+ const payload = e.payload || {};
110
+ const meta = e.metadata || {};
111
+ const h = (payload.handoff && typeof payload.handoff === 'object') ? payload.handoff
112
+ : (meta.handoff && typeof meta.handoff === 'object') ? meta.handoff : null;
113
+ if (!h) continue;
114
+ out.push({
115
+ id: e.id || null,
116
+ timestamp: e.timestamp || null,
117
+ from: h.from || String(e.source || '').replace(/^openagents:/, ''),
118
+ to: h.to || null,
119
+ request: h.request || null,
120
+ context: h.context || null,
121
+ output: h.output || null,
122
+ next_owner: h.next_owner || h.to || null,
123
+ });
124
+ if (out.length >= limit) break;
125
+ }
126
+ return out;
127
+ }
128
+
129
+ function describeAvailability(agent, a) {
130
+ const bits = [`${agent}: ${a.reason || 'unknown'}`];
131
+ bits.push(`status=${a.status || '?'}`);
132
+ if (a.presence_state) bits.push(`presence=${a.presence_state}`);
133
+ if (Array.isArray(a.busy_channels) && a.busy_channels.length) bits.push(`busy in: ${a.busy_channels.join(', ')}`);
134
+ if (a.queue_depth) bits.push(`queued: ${a.queue_depth}`);
135
+ if (a.runtime_status) bits.push(`runtime ${a.runtime_status}${a.runtime_name ? ` (${a.runtime_name})` : ''}`);
136
+ const hint = {
137
+ available: 'It can take work now.',
138
+ busy: 'It will answer after the current run — a hand-off is queued, not lost.',
139
+ agent_offline: 'Its connector is not running; a message will wait until it reconnects.',
140
+ runtime_offline: 'The device it runs on is offline; nobody will pick this up until it is back.',
141
+ }[a.reason];
142
+ return bits.join(' · ') + (hint ? `\n${hint}` : '');
143
+ }
144
+
145
+ async function handle(server, name, args, helpers) {
146
+ const { text } = helpers;
147
+ args = args || {};
148
+ switch (name) {
149
+ case 'workspace_agent_availability': {
150
+ const agent = cleanName(args.agent);
151
+ if (!agent) throw new Error('agent is required');
152
+ const a = await server.ws.getAgentAvailability(server.workspaceId, server.token, agent);
153
+ return text(describeAvailability(agent, a || {}));
154
+ }
155
+
156
+ case 'workspace_handoff': {
157
+ const { handoff, content } = buildHandoff(server.agentName, args);
158
+ if (handoff.to === handoff.from) throw new Error('You cannot hand work to yourself');
159
+ const event = {
160
+ type: 'workspace.message.posted',
161
+ source: `openagents:${server.agentName}`,
162
+ target: `channel/${server.channelName}`,
163
+ payload: { content, message_type: 'chat', handoff },
164
+ metadata: { handoff, explicit_targets: [handoff.to] },
165
+ };
166
+ if (server.requesterEmail) event.metadata.requester_email = server.requesterEmail;
167
+ const result = await server.ws.sendEvent(server.workspaceId, event, server.token);
168
+ const targets = (result && result.metadata && result.metadata.target_agents) || [];
169
+ const delivered = targets.includes(handoff.to);
170
+ return text(
171
+ `Handed off to @${handoff.to}${delivered ? '' : ' (warning: the workspace did not route it — is that agent in this workspace?)'}. ` +
172
+ `Next owner: ${handoff.next_owner}. End your turn now unless you have other work; ` +
173
+ `@${handoff.to} will reply in this thread.`,
174
+ );
175
+ }
176
+
177
+ case 'workspace_get_handoffs': {
178
+ const channel = args.channel || server.channelName;
179
+ const limit = Math.min(Math.max(1, parseInt(args.limit, 10) || MAX_HANDOFFS), MAX_HANDOFFS);
180
+ const events = await server.ws.getChannelEvents(server.workspaceId, channel, server.token, { limit: 200, sort: 'desc' });
181
+ const handoffs = extractHandoffs(events, limit);
182
+ if (!handoffs.length) return text(`No hand-offs in ${channel} yet.`);
183
+ const lines = handoffs.map((h, i) => {
184
+ const parts = [`${i + 1}. ${h.from || '?'} → ${h.to || '?'}: ${h.request || ''}`];
185
+ if (h.context) parts.push(` Context: ${h.context}`);
186
+ if (h.output) parts.push(` Output so far: ${h.output}`);
187
+ if (h.next_owner) parts.push(` Next owner: ${h.next_owner}`);
188
+ if (h.timestamp) parts.push(` at ${new Date(h.timestamp).toISOString()}`);
189
+ return parts.join('\n');
190
+ });
191
+ return text(`Hand-offs in ${channel} (newest first):\n${lines.join('\n')}`);
192
+ }
193
+
194
+ default:
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ module.exports = { defs, handle, buildHandoff, extractHandoffs };
@@ -41,6 +41,30 @@ class SessionRevokedError extends Error {
41
41
  }
42
42
  }
43
43
 
44
+ /**
45
+ * Reject when a response is cut off after its headers arrived.
46
+ *
47
+ * Node reports that on the RESPONSE, and nowhere else: once a response
48
+ * exists the request emits no 'error' for a connection that closes early,
49
+ * 'end' only fires for a complete body, and the request's abort signal is a
50
+ * no-op by then because the socket is already gone. A request helper that
51
+ * listens for 'end' alone therefore never settles — and each poll loop is a
52
+ * single await, so one truncated response (a backend restart mid-reply is
53
+ * enough) silences that loop for the life of the daemon. When it is the
54
+ * control poller the agent keeps working and answering, and ignores Stop.
55
+ */
56
+ function rejectOnTruncatedResponse(res, reject) {
57
+ res.on('error', reject);
58
+ res.on('close', () => {
59
+ if (res.complete) return;
60
+ // Same code Node puts on the 'error' above, so callers count either as
61
+ // the network blip it is.
62
+ const err = new Error('Response ended before it was complete');
63
+ err.code = 'ECONNRESET';
64
+ reject(err);
65
+ });
66
+ }
67
+
44
68
  /**
45
69
  * HTTP client for workspace API operations.
46
70
  *
@@ -48,8 +72,34 @@ class SessionRevokedError extends Error {
48
72
  * auth headers (X-Workspace-Token), same request/response shapes.
49
73
  */
50
74
  class WorkspaceClient {
51
- constructor(endpoint) {
75
+ /**
76
+ * @param {string} [endpoint]
77
+ * @param {{ agentName?: string }} [opts] — when the client acts for ONE
78
+ * agent, pass its name: every request then carries `X-Agent-Name` so the
79
+ * workspace can apply that agent's permissions (permission model v1.1 §3)
80
+ * instead of treating the call as an anonymous machine. Machine-level
81
+ * clients (daemon device heartbeat, pairing redeem, workspace removal)
82
+ * pass nothing and send no agent identity.
83
+ */
84
+ constructor(endpoint, { agentName } = {}) {
52
85
  this.endpoint = (endpoint || DEFAULT_ENDPOINT).replace(/\/$/, '');
86
+ this.agentName = typeof agentName === 'string' && agentName.trim() ? agentName.trim() : null;
87
+ }
88
+
89
+ /** A client for the same endpoint that acts as `agentName` (or none). */
90
+ withAgent(agentName) {
91
+ return new WorkspaceClient(this.endpoint, { agentName });
92
+ }
93
+
94
+ /**
95
+ * Request headers + the agent identity header. Applied in every transport
96
+ * helper (_get/_getRaw/_post/_put/_patch/_delete) so no call site can forget
97
+ * it. Returns a copy — callers' header objects are never mutated.
98
+ */
99
+ _withAgent(headers) {
100
+ const out = { ...(headers || {}) };
101
+ if (this.agentName && !out['X-Agent-Name']) out['X-Agent-Name'] = this.agentName;
102
+ return out;
53
103
  }
54
104
 
55
105
  /**
@@ -217,16 +267,80 @@ class WorkspaceClient {
217
267
  * If the server's current session for this agent differs, _post()
218
268
  * throws SessionRevokedError and the caller should stop its adapter.
219
269
  */
220
- async heartbeat(workspaceId, agentName, token, sessionId) {
270
+ async heartbeat(workspaceId, agentName, token, sessionId, presence) {
221
271
  const body = {
222
272
  agent_name: agentName,
223
273
  network: workspaceId,
224
274
  };
225
275
  if (sessionId) body.session_id = sessionId;
276
+ // v1.1 M3 — busy/queued detail ({ presence_state, busy_channels,
277
+ // queue_depth }, see BaseAdapter.buildPresenceFields). Optional so older
278
+ // callers keep sending the legacy body.
279
+ if (presence && typeof presence === 'object') {
280
+ if (presence.presence_state !== undefined) body.presence_state = presence.presence_state;
281
+ if (Array.isArray(presence.busy_channels)) body.busy_channels = presence.busy_channels;
282
+ if (Number.isFinite(presence.queue_depth)) body.queue_depth = presence.queue_depth;
283
+ }
226
284
  const data = await this._post('/v1/heartbeat', body, this._wsHeaders(token));
227
285
  return data.data || data;
228
286
  }
229
287
 
288
+ // ── Agent profile / presence (v1.1 M3/M4) ────────────────────────────────
289
+
290
+ /** GET /v1/agents/{agent}/availability → { status, presence_state,
291
+ * busy_channels, queue_depth, runtime_status, runtime_name, reason }. */
292
+ async getAgentAvailability(workspaceId, token, agentName) {
293
+ const data = await this._get(
294
+ `/v1/agents/${encodeURIComponent(agentName)}/availability?network=${encodeURIComponent(workspaceId)}`,
295
+ this._wsHeaders(token),
296
+ );
297
+ return data.data || data;
298
+ }
299
+
300
+ /** GET /v1/agents/{agent}/profile — the specialist card. */
301
+ async getAgentProfile(workspaceId, token, agentName) {
302
+ const data = await this._get(
303
+ `/v1/agents/${encodeURIComponent(agentName)}/profile?network=${encodeURIComponent(workspaceId)}`,
304
+ this._wsHeaders(token),
305
+ );
306
+ return data.data || data;
307
+ }
308
+
309
+ /**
310
+ * GET /v1/agents/{agent}/shared-context — what THIS agent's run may use when
311
+ * the request comes from a teammate rather than its owner. Machine-only.
312
+ * Returns { apply, owner_email, requester_email, shared_instructions,
313
+ * allowed_knowledge: [{ slug, title, content }], cost_owner }.
314
+ */
315
+ async getSharedContext(workspaceId, token, agentName, { requesterEmail, channel, timeout } = {}) {
316
+ const params = new URLSearchParams({ network: workspaceId });
317
+ if (requesterEmail) params.set('requester_email', requesterEmail);
318
+ if (channel) params.set('channel', channel);
319
+ const data = await this._get(
320
+ `/v1/agents/${encodeURIComponent(agentName)}/shared-context?${params}`,
321
+ this._wsHeaders(token),
322
+ timeout || 5000,
323
+ );
324
+ return data.data || data;
325
+ }
326
+
327
+ /**
328
+ * Raw message events of a channel (newest first by default) — for callers
329
+ * that need payload/metadata fields _eventToMessage drops (e.g. hand-offs).
330
+ */
331
+ async getChannelEvents(workspaceId, channelName, token, { limit = 100, sort = 'desc' } = {}) {
332
+ const params = new URLSearchParams({
333
+ network: workspaceId,
334
+ channel: channelName,
335
+ type: 'workspace.message',
336
+ sort,
337
+ limit: String(Math.min(Math.max(1, limit), 500)),
338
+ });
339
+ const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
340
+ const result = data.data || data;
341
+ return (result && result.events) || [];
342
+ }
343
+
230
344
  /**
231
345
  * Disconnect agent via POST /v1/leave. Best-effort (ignores errors).
232
346
  */
@@ -323,7 +437,7 @@ class WorkspaceClient {
323
437
  * previous session fails (the channel's chat history is the only thing
324
438
  * that survives a session-storage rotation).
325
439
  */
326
- async getRecentMessages(workspaceId, channelName, token, limit = 30, { sort = 'desc' } = {}) {
440
+ async getRecentMessages(workspaceId, channelName, token, limit = 30, { sort = 'desc', before, timeout, throwOnError = false } = {}) {
327
441
  try {
328
442
  const params = new URLSearchParams({
329
443
  network: workspaceId,
@@ -332,14 +446,16 @@ class WorkspaceClient {
332
446
  sort,
333
447
  limit: String(limit),
334
448
  });
335
- const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
449
+ if (before) params.set('before', before);
450
+ const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token), timeout);
336
451
  const result = data.data || data;
337
452
  const events = (result && result.events) || [];
338
453
  // A desc window arrives newest-first; reverse so the caller always gets
339
454
  // chronological order. An asc window is already chronological.
340
455
  const ordered = sort === 'asc' ? events.slice() : events.slice().reverse();
341
456
  return ordered.map((e) => this._eventToMessage(e));
342
- } catch {
457
+ } catch (error) {
458
+ if (throwOnError) throw error;
343
459
  return [];
344
460
  }
345
461
  }
@@ -587,8 +703,17 @@ class WorkspaceClient {
587
703
  /**
588
704
  * Upload a file via POST /v1/files/base64.
589
705
  */
706
+ async publishWebProject(workspaceId, token, project, projectId) {
707
+ const body = { ...project, network: workspaceId };
708
+ const route = '/v1/web-projects' + (projectId ? '/' + encodeURIComponent(projectId) : '');
709
+ const data = projectId
710
+ ? await this._put(route, body, this._wsHeaders(token), 60000)
711
+ : await this._post(route, body, this._wsHeaders(token), 60000);
712
+ return data.data || data;
713
+ }
714
+
590
715
  async uploadFile(workspaceId, token, filename, contentBase64, {
591
- contentType = 'application/octet-stream', source = 'human:user', channelName,
716
+ contentType = 'application/octet-stream', source = 'human:user', channelName, visibility,
592
717
  } = {}) {
593
718
  const body = {
594
719
  filename,
@@ -598,6 +723,7 @@ class WorkspaceClient {
598
723
  source,
599
724
  };
600
725
  if (channelName) body.channel_name = channelName;
726
+ if (visibility) body.visibility = visibility;
601
727
 
602
728
  const data = await this._post('/v1/files/base64', body, this._wsHeaders(token), 60000);
603
729
  return data.data || data;
@@ -675,8 +801,8 @@ class WorkspaceClient {
675
801
  /**
676
802
  * Open a new browser tab via POST /v1/browser/tabs.
677
803
  */
678
- async browserOpenTab(workspaceId, token, { url = 'about:blank', source = 'human:user', context_id } = {}) {
679
- const body = { url, network: workspaceId, source };
804
+ async browserOpenTab(workspaceId, token, { url = 'about:blank', source = 'human:user', context_id, channel } = {}) {
805
+ const body = { url, network: workspaceId, source, channel };
680
806
  if (context_id) body.context_id = context_id;
681
807
  const data = await this._post('/v1/browser/tabs', body, this._wsHeaders(token));
682
808
  return data.data || data;
@@ -694,24 +820,24 @@ class WorkspaceClient {
694
820
  /**
695
821
  * Navigate a browser tab via POST /v1/browser/tabs/{tabId}/navigate.
696
822
  */
697
- async browserNavigate(workspaceId, token, tabId, url) {
698
- const data = await this._post(`/v1/browser/tabs/${tabId}/navigate`, { url }, this._wsHeaders(token));
823
+ async browserNavigate(workspaceId, token, tabId, url, { source, channel } = {}) {
824
+ const data = await this._post(`/v1/browser/tabs/${tabId}/navigate`, { url, source, channel }, this._wsHeaders(token));
699
825
  return data.data || data;
700
826
  }
701
827
 
702
828
  /**
703
829
  * Click an element via POST /v1/browser/tabs/{tabId}/click.
704
830
  */
705
- async browserClick(workspaceId, token, tabId, selector) {
706
- const data = await this._post(`/v1/browser/tabs/${tabId}/click`, { selector }, this._wsHeaders(token));
831
+ async browserClick(workspaceId, token, tabId, selector, { source, channel } = {}) {
832
+ const data = await this._post(`/v1/browser/tabs/${tabId}/click`, { selector, source, channel }, this._wsHeaders(token));
707
833
  return data.data || data;
708
834
  }
709
835
 
710
836
  /**
711
837
  * Type text via POST /v1/browser/tabs/{tabId}/type.
712
838
  */
713
- async browserType(workspaceId, token, tabId, selector, text) {
714
- const data = await this._post(`/v1/browser/tabs/${tabId}/type`, { selector, text }, this._wsHeaders(token));
839
+ async browserType(workspaceId, token, tabId, selector, text, { source, channel } = {}) {
840
+ const data = await this._post(`/v1/browser/tabs/${tabId}/type`, { selector, text, source, channel }, this._wsHeaders(token));
715
841
  return data.data || data;
716
842
  }
717
843
 
@@ -719,16 +845,22 @@ class WorkspaceClient {
719
845
  * Get screenshot via GET /v1/browser/tabs/{tabId}/screenshot.
720
846
  * @returns {Buffer}
721
847
  */
722
- async browserScreenshot(workspaceId, token, tabId) {
723
- return this._getRaw(`/v1/browser/tabs/${tabId}/screenshot`, this._wsHeaders(token), 30000);
848
+ async browserScreenshot(workspaceId, token, tabId, { source, channel } = {}) {
849
+ const query = new URLSearchParams();
850
+ if (source) query.set('source', source);
851
+ if (channel) query.set('channel', channel);
852
+ return this._getRaw(`/v1/browser/tabs/${tabId}/screenshot?${query}`, this._wsHeaders(token), 30000);
724
853
  }
725
854
 
726
855
  /**
727
856
  * Get accessibility snapshot via GET /v1/browser/tabs/{tabId}/snapshot.
728
857
  * @returns {string}
729
858
  */
730
- async browserSnapshot(workspaceId, token, tabId) {
731
- const buf = await this._getRaw(`/v1/browser/tabs/${tabId}/snapshot`, this._wsHeaders(token));
859
+ async browserSnapshot(workspaceId, token, tabId, { source, channel } = {}) {
860
+ const query = new URLSearchParams();
861
+ if (source) query.set('source', source);
862
+ if (channel) query.set('channel', channel);
863
+ const buf = await this._getRaw(`/v1/browser/tabs/${tabId}/snapshot?${query}`, this._wsHeaders(token));
732
864
  return buf.toString('utf-8');
733
865
  }
734
866
 
@@ -943,11 +1075,25 @@ class WorkspaceClient {
943
1075
  sessionId,
944
1076
  senderType: isHuman ? 'human' : 'agent',
945
1077
  senderName,
1078
+ senderDisplayName: payload.sender_display_name || (event.metadata || {}).sender_display_name || null,
946
1079
  content: (payload.content || event.content || ''),
947
1080
  mentions: payload.mentions || [],
948
1081
  messageType: payload.message_type || 'chat',
949
1082
  metadata: event.metadata || {},
950
1083
  };
1084
+ // The person behind a human message (v1.1): mobile puts it in the
1085
+ // payload, the Launcher in metadata, the web app uses its email as the
1086
+ // sender id. Used to fetch the owner-reviewed shared context for a
1087
+ // teammate's request and to tell the owner who asked.
1088
+ if (isHuman) {
1089
+ const meta = event.metadata || {};
1090
+ const email = [payload.sender_email, meta.sender_email, payload.sender_id]
1091
+ .find((v) => typeof v === 'string' && /^[^\s@]+@[^\s@]+$/.test(v.trim()));
1092
+ if (email) msg.senderEmail = email.trim().toLowerCase();
1093
+ }
1094
+ if (payload.handoff && typeof payload.handoff === 'object') {
1095
+ msg.handoff = payload.handoff;
1096
+ }
951
1097
  if (ts) {
952
1098
  msg.createdAt = new Date(ts).toISOString();
953
1099
  }
@@ -957,6 +1103,60 @@ class WorkspaceClient {
957
1103
  return msg;
958
1104
  }
959
1105
 
1106
+ // ── Approvals ─────────────────────────────────────────────────────────────
1107
+
1108
+ /** File an approval request (POST /v1/approvals). Returns the record; its
1109
+ * `status` may already be approved/rejected when policy auto-decided. */
1110
+ async createApproval(workspaceId, token, {
1111
+ channel, kind, action, details, risk, source, assignee_email, requester_email,
1112
+ } = {}) {
1113
+ const body = {
1114
+ network: workspaceId,
1115
+ channel,
1116
+ kind: kind || 'other',
1117
+ action,
1118
+ source: source || 'openagents:unknown',
1119
+ };
1120
+ if (details) body.details = String(details).slice(0, 4000);
1121
+ if (risk) body.risk = risk;
1122
+ if (assignee_email) body.assignee_email = assignee_email;
1123
+ if (requester_email) body.requester_email = requester_email;
1124
+ const data = await this._post('/v1/approvals', body, this._wsHeaders(token));
1125
+ return data.data || data;
1126
+ }
1127
+
1128
+ async getApproval(workspaceId, token, approvalId) {
1129
+ const params = new URLSearchParams({ network: workspaceId });
1130
+ const data = await this._get(`/v1/approvals/${encodeURIComponent(approvalId)}?${params}`, this._wsHeaders(token));
1131
+ return data.data || data;
1132
+ }
1133
+
1134
+ // ── Work brief (v1.1 M5) ──────────────────────────────────────────────────
1135
+
1136
+ /** Read a thread's shared work brief (GET /v1/channels/{channel}/brief).
1137
+ * Empty fields come back null / [] when nothing has been written yet. */
1138
+ async getBrief(workspaceId, token, channelName) {
1139
+ const params = new URLSearchParams({ network: workspaceId });
1140
+ const data = await this._get(
1141
+ `/v1/channels/${encodeURIComponent(channelName)}/brief?${params}`,
1142
+ this._wsHeaders(token),
1143
+ );
1144
+ return data.data || data;
1145
+ }
1146
+
1147
+ /** Partial upsert of a thread's brief (PUT /v1/channels/{channel}/brief).
1148
+ * Only the keys present in `fields` change; `source` is recorded as the
1149
+ * writer ("openagents:<agent>"). */
1150
+ async putBrief(workspaceId, token, channelName, fields = {}, { source } = {}) {
1151
+ const body = { network: workspaceId, source: source || 'openagents:unknown', ...fields };
1152
+ const data = await this._put(
1153
+ `/v1/channels/${encodeURIComponent(channelName)}/brief`,
1154
+ body,
1155
+ this._wsHeaders(token),
1156
+ );
1157
+ return data.data || data;
1158
+ }
1159
+
960
1160
  _wsHeaders(token) {
961
1161
  return {
962
1162
  'Content-Type': 'application/json',
@@ -965,6 +1165,7 @@ class WorkspaceClient {
965
1165
  }
966
1166
 
967
1167
  _get(urlPath, headers = {}, timeout = 15000) {
1168
+ headers = this._withAgent(headers);
968
1169
  const fullUrl = this.endpoint + urlPath;
969
1170
 
970
1171
  return new Promise((resolve, reject) => {
@@ -985,6 +1186,7 @@ class WorkspaceClient {
985
1186
  signal: AbortSignal.timeout(timeout),
986
1187
  }, (res) => {
987
1188
  let data = '';
1189
+ rejectOnTruncatedResponse(res, reject);
988
1190
  res.on('data', (chunk) => { data += chunk; });
989
1191
  res.on('end', () => {
990
1192
  try {
@@ -1014,6 +1216,7 @@ class WorkspaceClient {
1014
1216
  }
1015
1217
 
1016
1218
  _getRaw(urlPath, headers = {}, timeout = 15000) {
1219
+ headers = this._withAgent(headers);
1017
1220
  const fullUrl = this.endpoint + urlPath;
1018
1221
 
1019
1222
  return new Promise((resolve, reject) => {
@@ -1027,6 +1230,7 @@ class WorkspaceClient {
1027
1230
  signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
1028
1231
  }, (res) => {
1029
1232
  const chunks = [];
1233
+ rejectOnTruncatedResponse(res, reject);
1030
1234
  res.on('data', (chunk) => { chunks.push(chunk); });
1031
1235
  res.on('end', () => {
1032
1236
  const buf = Buffer.concat(chunks);
@@ -1045,6 +1249,7 @@ class WorkspaceClient {
1045
1249
  }
1046
1250
 
1047
1251
  _post(urlPath, body, headers = {}, timeout = 30000) {
1252
+ headers = this._withAgent(headers);
1048
1253
  if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
1049
1254
  const jsonBody = JSON.stringify(body);
1050
1255
  const fullUrl = this.endpoint + urlPath;
@@ -1060,6 +1265,7 @@ class WorkspaceClient {
1060
1265
  signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
1061
1266
  }, (res) => {
1062
1267
  let data = '';
1268
+ rejectOnTruncatedResponse(res, reject);
1063
1269
  res.on('data', (chunk) => { data += chunk; });
1064
1270
  res.on('end', () => {
1065
1271
  try {
@@ -1096,6 +1302,7 @@ class WorkspaceClient {
1096
1302
  }
1097
1303
 
1098
1304
  _put(urlPath, body, headers = {}, timeout = 30000) {
1305
+ headers = this._withAgent(headers);
1099
1306
  if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
1100
1307
  const jsonBody = JSON.stringify(body);
1101
1308
  const fullUrl = this.endpoint + urlPath;
@@ -1111,6 +1318,7 @@ class WorkspaceClient {
1111
1318
  signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
1112
1319
  }, (res) => {
1113
1320
  let data = '';
1321
+ rejectOnTruncatedResponse(res, reject);
1114
1322
  res.on('data', (chunk) => { data += chunk; });
1115
1323
  res.on('end', () => {
1116
1324
  try {
@@ -1139,6 +1347,7 @@ class WorkspaceClient {
1139
1347
  }
1140
1348
 
1141
1349
  _patch(urlPath, body, headers = {}, timeout = 15000) {
1350
+ headers = this._withAgent(headers);
1142
1351
  if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
1143
1352
  const jsonBody = JSON.stringify(body);
1144
1353
  const fullUrl = this.endpoint + urlPath;
@@ -1154,6 +1363,7 @@ class WorkspaceClient {
1154
1363
  signal: AbortSignal.timeout(timeout), // hard deadline (covers DNS/connect); see _get
1155
1364
  }, (res) => {
1156
1365
  let data = '';
1366
+ rejectOnTruncatedResponse(res, reject);
1157
1367
  res.on('data', (chunk) => { data += chunk; });
1158
1368
  res.on('end', () => {
1159
1369
  try {
@@ -1177,6 +1387,7 @@ class WorkspaceClient {
1177
1387
  }
1178
1388
 
1179
1389
  _delete(urlPath, headers = {}, network) {
1390
+ headers = this._withAgent(headers);
1180
1391
  const fullUrl = this.endpoint + urlPath + (network ? `?network=${network}` : '');
1181
1392
 
1182
1393
  return new Promise((resolve, reject) => {
@@ -1190,6 +1401,7 @@ class WorkspaceClient {
1190
1401
  signal: AbortSignal.timeout(15000), // hard deadline (covers DNS/connect); see _get
1191
1402
  }, (res) => {
1192
1403
  let data = '';
1404
+ rejectOnTruncatedResponse(res, reject);
1193
1405
  res.on('data', (chunk) => { data += chunk; });
1194
1406
  res.on('end', () => {
1195
1407
  try {