@openagents-org/agent-launcher 0.2.193 → 0.2.195

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/README.md CHANGED
@@ -10,6 +10,24 @@ npm install -g @openagents-org/agent-connector
10
10
 
11
11
  ## CLI Usage
12
12
 
13
+ ### Shared thread context
14
+
15
+ Before each triggered agent turn in a workspace thread, the connector reads
16
+ recent messages from all participants, including human-to-human discussion.
17
+ It includes up to 30 chat messages from the latest 100 events before the
18
+ trigger, in chronological order with speaker names. The transcript is bounded
19
+ to 16,000 characters, with long messages shortened to 2,000 characters each.
20
+ Status updates and the triggering message are excluded from the transcript;
21
+ the current request remains separate. Older messages remain available through
22
+ the workspace history tool.
23
+
24
+ This does not change mention or response-mode rules. Direct messages keep
25
+ their existing conversation handling. If history cannot be fetched within
26
+ five seconds, the connector logs the failure and continues with the request.
27
+ Existing installations need the updated connector and an agent restart.
28
+
29
+ ### Commands
30
+
13
31
  ```bash
14
32
  # Browse available agents
15
33
  agent-connector search
@@ -32,8 +50,14 @@ agent-connector status
32
50
  # View logs
33
51
  agent-connector logs
34
52
 
35
- # Connect to a workspace
36
- agent-connector connect my-agent <token>
53
+ # Connect this device (formerly "node") to a workspace with a pairing code
54
+ # from Workspace → Devices → Connect a device. Pair once; agents created on
55
+ # this device can then join without a token. `agn node ...` still works.
56
+ agent-connector device connect XXXX-XXXX
57
+ agent-connector device status
58
+
59
+ # Connect an agent to a paired workspace
60
+ agent-connector connect my-agent --workspace <slug>
37
61
 
38
62
  # Stop
39
63
  agent-connector down
@@ -41,6 +65,12 @@ agent-connector down
41
65
 
42
66
  Run `agent-connector help` for the full command list.
43
67
 
68
+ Only the person who connected a device controls it from the workspace
69
+ (remote add/start/stop of its agents); a workspace admin can only unpair it.
70
+ Every request the connector makes for an agent carries `X-Agent-Name`, so the
71
+ workspace applies that agent's permissions rather than treating the call as an
72
+ anonymous machine.
73
+
44
74
  ## Library Usage
45
75
 
46
76
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.193",
3
+ "version": "0.2.195",
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/registry.json CHANGED
@@ -329,7 +329,7 @@
329
329
  },
330
330
  "check_ready": {
331
331
  "binary": "agent",
332
- "not_ready_message": "Cursor is installed but not signed in — run `cursor-agent login` on the device, or set a CURSOR_API_KEY from cursor.com/settings. Third-party or relay API keys do not work with Cursor.",
332
+ "not_ready_message": "Cursor is installed but not signed in — run `cursor-agent login` on the device (this is separate from signing in to the Cursor editor), or set a CURSOR_API_KEY from cursor.com/settings. Third-party or relay API keys do not work with Cursor.",
333
333
  "env_vars": [
334
334
  "CURSOR_API_KEY"
335
335
  ],
@@ -337,7 +337,11 @@
337
337
  "creds_key": "authInfo",
338
338
  "creds_no_parse": true,
339
339
  "unverifiable": true,
340
- "login_command": "cursor-agent login"
340
+ "login_command": "cursor-agent login",
341
+ "status_args": [
342
+ "status"
343
+ ],
344
+ "logged_out_pattern": "not logged in|not signed in|not authenticated|unauthenticated|logged out|signed out|login required|partially authenticated"
341
345
  },
342
346
  "env_config": [
343
347
  {
@@ -1323,9 +1327,11 @@
1323
1327
  "env_vars": [
1324
1328
  "GEMINI_API_KEY"
1325
1329
  ],
1330
+ "credential_target": "gemini:antigravity",
1331
+ "unverifiable": true,
1326
1332
  "saved_env_key": "GEMINI_API_KEY",
1327
1333
  "login_command": "agy",
1328
- "not_ready_message": "Needs sign-in — run `agy` to sign in with Google, or set GEMINI_API_KEY.",
1334
+ "not_ready_message": "Sign-in not confirmed. Run `agy` in a terminal to sign in with Google, or set GEMINI_API_KEY. On Windows the sign-in is detected once it lands in Credential Manager; elsewhere agy keeps it out of reach, so it is confirmed when you run a task.",
1329
1335
  "auth_detected_labels": {
1330
1336
  "cli_login": "Google account sign-in detected",
1331
1337
  "api_key": "API key detected"
@@ -158,7 +158,21 @@ class AgyRunState {
158
158
  }
159
159
  }
160
160
 
161
- const AUTH_RE = /authentication (required|failed)|not authenticated|sign[ -]?in|log[ -]?in required|no valid credentials|GEMINI_API_KEY/i;
161
+ // Auth phrasings agy and the Gemini API behind it actually produce. The
162
+ // underscored API status codes are spelled out rather than matched as plain
163
+ // English ("permission denied" alone is just as likely to be a file a tool
164
+ // couldn't read), and an expired/missing token counts as auth because the fix
165
+ // is the same sign-in either way.
166
+ const AUTH_RE =
167
+ /authentication (required|failed)|not authenticated|unauthenticated|sign[ -]?in|log[ -]?in required|no valid credentials|credentials? (not found|missing|expired|invalid)|(access|auth|refresh|id) token (expired|invalid|missing)|permission_denied|GEMINI_API_KEY/i;
168
+ // agy 1.2.2, verbatim, on an account Antigravity will not serve:
169
+ // Eligibility check failed: Your current account is not eligible for
170
+ // Antigravity, because it is not currently available in your location.
171
+ // It is checked BEFORE the auth patterns because it is the one failure a
172
+ // sign-in cannot fix — the account is signed in, which is exactly why telling
173
+ // the user to sign in again is the wrong answer.
174
+ const ELIGIBILITY_RE =
175
+ /eligibility check failed|not (currently )?available in your (location|country|region)/i;
162
176
  const PROVIDER_RE = /modelProvider|model provider/i;
163
177
  const MODEL_RE = /unknown model|invalid model/i;
164
178
  const TIMEOUT_RE = /print-timeout|timed? ?out/i;
@@ -177,6 +191,17 @@ function classifyAgyFailure({ code, stderr, error } = {}) {
177
191
  typeof error === 'string' ? error : [err.type, err.message].filter(Boolean).join(' ');
178
192
  const text = [errText, stderr].filter(Boolean).join('\n');
179
193
 
194
+ if (ELIGIBILITY_RE.test(text)) {
195
+ return {
196
+ kind: 'eligibility',
197
+ message:
198
+ 'Antigravity CLI refused the run because this Google account is not ' +
199
+ 'eligible for Antigravity — agy reports it is not available in your ' +
200
+ 'location. Signing in again will not change that. The other path this ' +
201
+ 'agent supports is GEMINI_API_KEY, which runs it against the Gemini API ' +
202
+ 'instead.',
203
+ };
204
+ }
180
205
  if (AUTH_RE.test(text)) {
181
206
  return {
182
207
  kind: 'auth',
@@ -206,15 +231,50 @@ function classifyAgyFailure({ code, stderr, error } = {}) {
206
231
  if (TIMEOUT_RE.test(text)) {
207
232
  return { kind: 'timeout', message: 'Antigravity CLI timed out before producing a response.' };
208
233
  }
209
- const detail = (errText || (stderr || '').trim().split('\n').pop() || '').slice(0, 200);
234
+ const detail = errText || stderrTail(stderr);
235
+ if (!detail) {
236
+ return {
237
+ kind: 'unknown',
238
+ message: `Antigravity CLI exited with code ${code ?? '?'} without a response.`,
239
+ };
240
+ }
241
+ // Unclassified, so the CLI's own words lead — but they are often a bare
242
+ // "Agent execution terminated due to error.", which tells the user nothing
243
+ // they can act on. Name the first thing worth checking without claiming to
244
+ // know it is the cause; agy says nothing more specific when it has no usable
245
+ // credentials, and that is far and away the most common way a run reaches
246
+ // here.
210
247
  return {
211
248
  kind: 'unknown',
212
- message: detail
213
- ? `Antigravity CLI failed (exit ${code ?? '?'}): ${detail}`
214
- : `Antigravity CLI exited with code ${code ?? '?'} without a response.`,
249
+ message:
250
+ `Antigravity CLI failed (exit ${code ?? '?'}):\n${detail}\n\n` +
251
+ 'If that does not say why, check the sign-in first — run `agy` once in a ' +
252
+ 'terminal to sign in with Google, or set GEMINI_API_KEY for this agent.',
215
253
  };
216
254
  }
217
255
 
256
+ /**
257
+ * The tail of a CLI's stderr, as a short quotable block.
258
+ *
259
+ * The last line alone used to be it, which threw away the informative half of
260
+ * every multi-line failure: agy prints its detail above a generic closing line,
261
+ * so what survived was exactly the part that says nothing. Colour codes are
262
+ * stripped — agy writes them even into a pipe, and they arrive as mojibake in a
263
+ * chat message.
264
+ */
265
+ function stderrTail(stderr, { lines = 3, limit = 600 } = {}) {
266
+ // eslint-disable-next-line no-control-regex
267
+ const plain = String(stderr || '').replace(/\u001B\[[0-9;?]*[ -\/]*[@-~]/g, '');
268
+ const kept = plain
269
+ .split('\n')
270
+ .map((l) => l.trimEnd())
271
+ .filter((l) => l.trim())
272
+ .slice(-lines)
273
+ .join('\n')
274
+ .trim();
275
+ return kept.length > limit ? kept.slice(-limit) : kept;
276
+ }
277
+
218
278
  /**
219
279
  * Filesystem candidates for the agy binary, beyond a PATH lookup. The install
220
280
  * script targets ~/.local/bin on macOS/Linux and %LOCALAPPDATA%\agy\bin on
@@ -20,6 +20,8 @@
20
20
  const { WorkspaceClient, SessionRevokedError } = require('../workspace-client');
21
21
  const { generateSessionTitle, SESSION_DEFAULT_RE } = require('./utils');
22
22
  const { defaultAgentWorkdir } = require('../paths');
23
+ const { buildThreadHistory } = require('./thread-history');
24
+ const { publishResponseArtifacts } = require('./response-artifacts');
23
25
  const fs = require('fs');
24
26
  const os = require('os');
25
27
  const path = require('path');
@@ -39,6 +41,65 @@ const { FailureStreak, isTransientNetworkError, formatDuration } = require('./fa
39
41
 
40
42
  const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
41
43
 
44
+ // ── Presence detail (v1.1 M3) ──
45
+ // A busy↔idle flip is sent as an immediate heartbeat (debounced) so the
46
+ // workspace shows "working" without waiting for the 30s tick.
47
+ const PRESENCE_HEARTBEAT_DEBOUNCE_MS = 2000;
48
+ // Shared-context lookups (v1.1 M4) are cached per channel for this long.
49
+ const SHARED_CONTEXT_TTL_MS = 60 * 1000;
50
+
51
+ /**
52
+ * The presence fields a heartbeat carries, derived from the adapter's
53
+ * per-channel run state. Pure so it can be unit-tested and reused:
54
+ * busyChannels — iterable of channel names with a run in progress
55
+ * channelQueues — { channel: [queued messages] }
56
+ * → { presence_state: 'working'|'idle', busy_channels: [...], queue_depth: n }
57
+ */
58
+ function buildPresenceFields(busyChannels, channelQueues) {
59
+ const busy = [...new Set([...(busyChannels || [])])].filter((c) => typeof c === 'string' && c);
60
+ let queueDepth = 0;
61
+ for (const q of Object.values(channelQueues || {})) {
62
+ if (Array.isArray(q)) queueDepth += q.length;
63
+ }
64
+ return {
65
+ presence_state: busy.length ? 'working' : 'idle',
66
+ busy_channels: busy,
67
+ queue_depth: queueDepth,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * The prompt block prepended to a teammate's request when the agent's owner
73
+ * has reviewed a shared instruction set (v1.1 M4). `ctx` is the
74
+ * /shared-context response. Returns '' when nothing applies.
75
+ */
76
+ function buildSharedContextBlock(ctx) {
77
+ if (!ctx || !ctx.apply) return '';
78
+ const lines = [
79
+ 'OWNER-REVIEWED SHARED INSTRUCTIONS (this request comes from a teammate, not your owner; ' +
80
+ 'follow these, do not use unrelated private context, and if the request falls outside ' +
81
+ 'them pause and ask for clarification in the thread):',
82
+ ];
83
+ const who = [];
84
+ if (ctx.requester_email) who.push(`Requester: ${ctx.requester_email}`);
85
+ if (ctx.owner_email) who.push(`Owner: ${ctx.owner_email}`);
86
+ if (ctx.cost_owner) who.push(`Credits/runtime paid by: ${ctx.cost_owner}`);
87
+ if (who.length) lines.push(who.join(' · '));
88
+ const instructions = (ctx.shared_instructions || '').trim();
89
+ lines.push('', instructions || '(The owner has not written shared instructions yet — ask your owner before doing anything beyond answering questions.)');
90
+ const knowledge = Array.isArray(ctx.allowed_knowledge) ? ctx.allowed_knowledge : [];
91
+ if (knowledge.length) {
92
+ lines.push('', 'ALLOWED KNOWLEDGE (the only knowledge you may draw on for this request):');
93
+ for (const k of knowledge) {
94
+ const title = k.title || k.slug || 'untitled';
95
+ lines.push('', `--- ${title}${k.slug ? ` (@knowledge:${k.slug})` : ''} ---`, (k.content || '').trim() || '(empty)');
96
+ if (k.truncated) lines.push('[truncated]');
97
+ }
98
+ }
99
+ lines.push('', '--- END OF SHARED INSTRUCTIONS — the teammate\'s request follows ---', '');
100
+ return lines.join('\n');
101
+ }
102
+
42
103
  // ── Poll-cursor persistence ──
43
104
  // An adapter that starts by jumping to the head of the event stream silently
44
105
  // drops every message that arrived while it was down (crash, update, user
@@ -61,6 +122,18 @@ const CURSOR_RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000;
61
122
  // heartbeat_failed up to the daemon. A success resets the streak immediately.
62
123
  const HEARTBEAT_ERROR_THRESHOLD = 2;
63
124
 
125
+ // ── Control-poller deadlines ──
126
+ // The control poller is one chain of awaits, and it is the only thing that
127
+ // delivers Stop. When a link in it never settles — it has: a control poll
128
+ // whose response was cut off mid-body — the agent goes on working and
129
+ // answering while every later control event sits unread until the daemon
130
+ // restarts, and nothing in the log says so. So neither the fetch nor a
131
+ // handler is awaited without a bound. A handler that overruns is not
132
+ // cancelled or re-run; the poller only stops waiting for it.
133
+ const CONTROL_POLL_DEADLINE_MS = 20 * 1000;
134
+ const CONTROL_ACTION_DEADLINE_MS = 60 * 1000;
135
+ const CONTROL_DEADLINE_HIT = Symbol('control deadline hit');
136
+
64
137
  /**
65
138
  * Model variables whose name does not follow `<TYPE>_MODEL`. Only the ones an
66
139
  * adapter really reads — see `modelLabel`, which tries `<TYPE>_MODEL` first
@@ -129,7 +202,8 @@ class BaseAdapter {
129
202
  // Set when the user explicitly stops this adapter (vs an error/revoke), so a
130
203
  // clean stop is never mislabeled as an error.
131
204
  this._stopRequested = false;
132
- this.client = new WorkspaceClient(this.endpoint);
205
+ // Acts for this one agent: X-Agent-Name on every request (permission model v1.1).
206
+ this.client = new WorkspaceClient(this.endpoint, { agentName: this.agentName });
133
207
  this._lastEventId = null;
134
208
  this._cursorFile = this._cursorFilePath();
135
209
  this._lastPersistedCursor = null;
@@ -145,6 +219,8 @@ class BaseAdapter {
145
219
  this.workspaceModel = null;
146
220
  this._lastControlId = null;
147
221
  this._controlWake = null;
222
+ this._CONTROL_POLL_DEADLINE_MS = CONTROL_POLL_DEADLINE_MS;
223
+ this._CONTROL_ACTION_DEADLINE_MS = CONTROL_ACTION_DEADLINE_MS;
148
224
  // Per-channel task tracking for parallel execution
149
225
  this._channelBusy = new Set();
150
226
  this._channelQueues = {};
@@ -162,6 +238,8 @@ class BaseAdapter {
162
238
  // adapter can tell "a stop landed while I was preparing this turn" from
163
239
  // "this turn started after the stop and is meant to run".
164
240
  this._channelRunGeneration = {};
241
+ this._channelRequestContent = {};
242
+ this._channelTriggerId = {};
165
243
  // Channels a user stopped whose next turn has not started yet. Everything
166
244
  // the send* helpers would post there is dropped: output a killed run was
167
245
  // still flushing lands after the stop notice otherwise, and the workspace
@@ -194,6 +272,14 @@ class BaseAdapter {
194
272
  // (Claude) fetch directly instead and leave it false.
195
273
  this._usesPinnedContext = false;
196
274
  this._pinnedContext = {}; // channel → { decisions, glossary }
275
+ // ── v1.1 M3/M4 ──
276
+ // Debounce handle for the busy↔idle presence heartbeat.
277
+ this._presenceHeartbeatTimer = null;
278
+ // channel → { fetchedAt, requesterEmail, ctx } from /shared-context.
279
+ this._sharedContext = {};
280
+ // channel → email of the person whose message is being handled (so the
281
+ // per-run MCP server can tell the owner who asked).
282
+ this._requesterEmail = {};
197
283
  // Wall-clock timestamp of adapter init, used by the `status` control
198
284
  // action to report uptime back to the channel. Reset on reinstantiation
199
285
  // (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
@@ -210,6 +296,9 @@ class BaseAdapter {
210
296
  // Runs of identical background failures (see failure-streak.js).
211
297
  this._pollFailures = new FailureStreak();
212
298
  this._heartbeatFailures = new FailureStreak();
299
+ // The control poller (Stop, mode changes) fails silently otherwise: its
300
+ // errors are caught so the loop keeps going, and nothing else logs them.
301
+ this._controlFailures = new FailureStreak();
213
302
  }
214
303
 
215
304
  // ------------------------------------------------------------------
@@ -370,6 +459,7 @@ class BaseAdapter {
370
459
  this._running = false;
371
460
  this._wakeControlPoller();
372
461
  clearInterval(heartbeatInterval);
462
+ if (this._presenceHeartbeatTimer) { clearTimeout(this._presenceHeartbeatTimer); this._presenceHeartbeatTimer = null; }
373
463
  try { await controlPoller; } catch {}
374
464
  try {
375
465
  await this.client.disconnect(this.workspaceId, this.agentName, this.token);
@@ -461,9 +551,30 @@ class BaseAdapter {
461
551
  // Heartbeat
462
552
  // ------------------------------------------------------------------
463
553
 
554
+ /** Presence fields for the next heartbeat (see buildPresenceFields). */
555
+ presenceFields() {
556
+ return buildPresenceFields(this._channelBusy, this._channelQueues);
557
+ }
558
+
559
+ /**
560
+ * A channel just went busy or idle (or its queue changed): send a heartbeat
561
+ * soon so the roster flips promptly instead of on the next 30s tick. Bursts
562
+ * collapse into one send.
563
+ */
564
+ _schedulePresenceHeartbeat() {
565
+ if (!this._running) return;
566
+ if (this._presenceHeartbeatTimer) return;
567
+ this._presenceHeartbeatTimer = setTimeout(() => {
568
+ this._presenceHeartbeatTimer = null;
569
+ if (!this._running) return;
570
+ this._heartbeat().catch(() => {});
571
+ }, PRESENCE_HEARTBEAT_DEBOUNCE_MS);
572
+ if (typeof this._presenceHeartbeatTimer.unref === 'function') this._presenceHeartbeatTimer.unref();
573
+ }
574
+
464
575
  async _heartbeat() {
465
576
  try {
466
- await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId);
577
+ await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId, this.presenceFields());
467
578
  this._heartbeatFailStreak = 0;
468
579
  const recovered = this._heartbeatFailures.succeed();
469
580
  if (recovered) {
@@ -518,10 +629,16 @@ class BaseAdapter {
518
629
 
519
630
  async _pollControl() {
520
631
  try {
521
- const events = await this.client.pollControl(
522
- this.workspaceId, this.agentName, this.token,
523
- { after: this._lastControlId }
632
+ const events = await this._withinControlDeadline(
633
+ this.client.pollControl(
634
+ this.workspaceId, this.agentName, this.token,
635
+ { after: this._lastControlId }
636
+ ),
637
+ this._CONTROL_POLL_DEADLINE_MS, 'control poll',
524
638
  );
639
+ // A poll abandoned at its deadline delivered nothing; the cursor has
640
+ // not moved, so the next one asks for the same events again.
641
+ if (events === CONTROL_DEADLINE_HIT) return;
525
642
  for (const ev of events) {
526
643
  if (ev.id) this._lastControlId = ev.id;
527
644
  const payload = ev.payload || {};
@@ -537,12 +654,48 @@ class BaseAdapter {
537
654
  // Handled here for every adapter, never by an override: a stop has
538
655
  // to stay inside the channel it names, and each adapter used to
539
656
  // decide that for itself — several stopped every channel.
540
- await this._handleUserStop(payload.channel || null);
657
+ await this._withinControlDeadline(
658
+ this._handleUserStop(payload.channel || null),
659
+ this._CONTROL_ACTION_DEADLINE_MS, `stop for ${payload.channel || 'every channel'}`,
660
+ );
541
661
  } else {
542
- await this._onControlAction(action, payload);
662
+ await this._withinControlDeadline(
663
+ this._onControlAction(action, payload),
664
+ this._CONTROL_ACTION_DEADLINE_MS, `control action '${action}'`,
665
+ );
543
666
  }
544
667
  }
545
- } catch {}
668
+ const recovered = this._controlFailures.succeed();
669
+ if (recovered) {
670
+ this._log(`Control poll recovered after ${recovered.count} unsuccessful attempt(s) over ${formatDuration(recovered.ms)}`);
671
+ }
672
+ } catch (e) {
673
+ // Keep polling, but leave a trace: a control channel that keeps failing
674
+ // means Stop and mode changes are not reaching this agent.
675
+ if (this._controlFailures.fail()) {
676
+ const count = this._controlFailures.count;
677
+ const streak = count > 1 ? ` (consecutive failures: ${count})` : '';
678
+ this._log(`Control poll failed: ${e && e.message}${streak}`, isTransientNetworkError(e) ? 'warn' : 'error');
679
+ }
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Await `work` for at most `ms`. Resolves to CONTROL_DEADLINE_HIT when the
685
+ * deadline wins; `work` is left to finish, or not, on its own.
686
+ */
687
+ _withinControlDeadline(work, ms, what) {
688
+ let timer;
689
+ const deadline = new Promise((resolve) => {
690
+ timer = setTimeout(() => {
691
+ this._log(`Control poller: ${what} still pending after ${Math.round(ms / 1000)}s — polling on without it`, 'warn');
692
+ resolve(CONTROL_DEADLINE_HIT);
693
+ }, ms);
694
+ });
695
+ return Promise.race([
696
+ Promise.resolve(work).finally(() => clearTimeout(timer)),
697
+ deadline,
698
+ ]);
546
699
  }
547
700
 
548
701
  /**
@@ -1216,6 +1369,7 @@ class BaseAdapter {
1216
1369
  msg._queueId = queueId;
1217
1370
  msg._acceptedGeneration = this._stopGenerationFor(channel);
1218
1371
  this._channelQueues[channel].push(msg);
1372
+ this._schedulePresenceHeartbeat();
1219
1373
  try {
1220
1374
  await this.sendStatus(channel, 'message queued — will process after current task', {
1221
1375
  queued_message: (msg.content || '').slice(0, 200),
@@ -1246,6 +1400,7 @@ class BaseAdapter {
1246
1400
 
1247
1401
  async _channelWorker(channel, msg) {
1248
1402
  this._channelBusy.add(channel);
1403
+ this._schedulePresenceHeartbeat();
1249
1404
  // Messages an adapter queues for itself (Claude's todo nudge) carry no
1250
1405
  // generation; they belong to the turn that queued them.
1251
1406
  const startGeneration = msg._acceptedGeneration !== undefined
@@ -1265,6 +1420,17 @@ class BaseAdapter {
1265
1420
  }
1266
1421
  // Pinned entries may have changed while this message waited.
1267
1422
  await this._prefetchPinnedContext(channel);
1423
+ this._channelRequestContent[channel] = current.originalContent ?? current.content;
1424
+ if (channel && !channel.startsWith('dm:') && current.messageId
1425
+ && typeof this.client?.getRecentMessages === 'function') {
1426
+ await this._applyThreadHistory(channel, current);
1427
+ }
1428
+ // A teammate's request runs under the owner-reviewed shared
1429
+ // instructions (v1.1 M4); the block is prepended to the message.
1430
+ // Gated synchronously so messages it cannot apply to (agents,
1431
+ // anonymous humans, system) add no extra tick before the stop check.
1432
+ this._applyThreadCollaboration(channel, current);
1433
+ if (this._sharedContextCandidate(current)) await this._applySharedContext(channel, current);
1268
1434
  // Checked after the round trips above, which a stop can land inside:
1269
1435
  // a message from before the stop never reaches the CLI.
1270
1436
  if (generation !== this._stopGenerationFor(channel)) {
@@ -1275,6 +1441,7 @@ class BaseAdapter {
1275
1441
  this._mutedChannels.delete(channel);
1276
1442
  this._stopNoticeSent.delete(channel);
1277
1443
  this._stoppingChannels.delete(channel);
1444
+ this._channelTriggerId[channel] = current.messageId || null;
1278
1445
  await this._handleMessage(current);
1279
1446
  }
1280
1447
  } catch (e) {
@@ -1282,6 +1449,8 @@ class BaseAdapter {
1282
1449
  try { await this.sendError(channel, `Agent error: ${e.message}`); } catch {}
1283
1450
  }
1284
1451
  delete this._channelRunGeneration[channel];
1452
+ delete this._channelRequestContent[channel];
1453
+ delete this._channelTriggerId[channel];
1285
1454
  first = false;
1286
1455
 
1287
1456
  const queue = this._channelQueues[channel];
@@ -1289,6 +1458,105 @@ class BaseAdapter {
1289
1458
  }
1290
1459
  if (dropped) this._log(`Stop in ${channel} — dropped ${dropped} message(s) accepted before it`);
1291
1460
  this._channelBusy.delete(channel);
1461
+ delete this._requesterEmail[channel];
1462
+ this._schedulePresenceHeartbeat();
1463
+ }
1464
+
1465
+ // ------------------------------------------------------------------
1466
+ // Shared context for teammates' requests (v1.1 M4)
1467
+ // ------------------------------------------------------------------
1468
+
1469
+ /** The email of the person whose request this channel is running, if known. */
1470
+ requesterEmailFor(channel) {
1471
+ return this._requesterEmail[channel] || null;
1472
+ }
1473
+
1474
+ /**
1475
+ * Fetch (cached 60s per channel + requester) the owner-reviewed shared
1476
+ * context for this run. Returns the /shared-context payload or null.
1477
+ */
1478
+ async _fetchSharedContext(channel, requesterEmail) {
1479
+ const cached = this._sharedContext[channel];
1480
+ const now = Date.now();
1481
+ if (cached && cached.requesterEmail === requesterEmail && (now - cached.fetchedAt) < SHARED_CONTEXT_TTL_MS) {
1482
+ return cached.ctx;
1483
+ }
1484
+ if (!this.client || typeof this.client.getSharedContext !== 'function') return null;
1485
+ try {
1486
+ const ctx = await this.client.getSharedContext(this.workspaceId, this.token, this.agentName, {
1487
+ requesterEmail, channel,
1488
+ });
1489
+ this._sharedContext[channel] = { fetchedAt: now, requesterEmail, ctx };
1490
+ return ctx;
1491
+ } catch (e) {
1492
+ this._log(`Shared-context fetch failed (${e && e.message ? e.message : e}) — running without it`);
1493
+ // Keep a stale-but-recent answer rather than flip-flopping on a blip.
1494
+ return cached && cached.requesterEmail === requesterEmail ? cached.ctx : null;
1495
+ }
1496
+ }
1497
+
1498
+ /**
1499
+ * When the triggering message comes from an identified person, look up the
1500
+ * shared context and, if it applies, prepend the instruction block to the
1501
+ * message the adapter will turn into its prompt. Records the requester for
1502
+ * the per-run MCP server. Never throws.
1503
+ */
1504
+ _sharedContextEmail(msg) {
1505
+ if (!msg) return null;
1506
+ const continuation = msg.senderType === 'agent' && msg.metadata?.orchestration?.action === 'next';
1507
+ if (msg.senderType !== 'human' && !continuation) return null;
1508
+ // Continuations retain the authenticated original requester so the next
1509
+ // agent applies the same owner-reviewed shared context and tool identity.
1510
+ const raw = continuation ? (msg.metadata.requester_email || '')
1511
+ : (msg.senderEmail || (msg.metadata && msg.metadata.sender_email) || '');
1512
+ const email = String(raw).trim().toLowerCase();
1513
+ return email && email.includes('@') ? email : null;
1514
+ }
1515
+
1516
+ _sharedContextCandidate(msg) {
1517
+ return this._sharedContextEmail(msg) !== null;
1518
+ }
1519
+
1520
+ _applyThreadCollaboration(channel, msg) {
1521
+ if (msg._threadCollaborationApplied || !channel || channel.startsWith('dm:')) return;
1522
+ const instructions = msg.metadata?.thread_agent_instructions?.[this.agentName];
1523
+ if (typeof instructions !== 'string' || !instructions.trim()) return;
1524
+ if (msg.originalContent === undefined) msg.originalContent = msg.content;
1525
+ msg.content = `COLLABORATION INSTRUCTIONS FOR THIS THREAD ONLY (do not carry these into other threads; existing owner restrictions still apply):\n${instructions.slice(0, 10000)}\nEND THREAD INSTRUCTIONS\n\n${msg.content || ''}`;
1526
+ msg._threadCollaborationApplied = true;
1527
+ }
1528
+
1529
+ async _applySharedContext(channel, msg) {
1530
+ const email = this._sharedContextEmail(msg);
1531
+ if (!email) return false;
1532
+ this._requesterEmail[channel] = email;
1533
+ if (msg._sharedContextApplied) return true;
1534
+ const ctx = await this._fetchSharedContext(channel, email);
1535
+ const block = buildSharedContextBlock(ctx);
1536
+ if (!block) return false;
1537
+ if (msg.originalContent === undefined) msg.originalContent = msg.content;
1538
+ msg.content = block + '\n' + (msg.content || '');
1539
+ msg._sharedContextApplied = true;
1540
+ this._log(`Shared context applied for ${email} in ${channel} (owner: ${ctx.owner_email || '?'})`);
1541
+ return true;
1542
+ }
1543
+
1544
+ async _applyThreadHistory(channel, msg) {
1545
+ // DMs have separate peer sessions. Synthetic nudges have no event boundary.
1546
+ if (!channel || channel.startsWith('dm:') || !msg.messageId || msg._threadHistoryApplied) return;
1547
+ if (typeof this.client?.getRecentMessages !== 'function') return;
1548
+ try {
1549
+ const messages = await this.client.getRecentMessages(this.workspaceId, channel, this.token, 100, {
1550
+ before: msg.messageId, timeout: 5000, throwOnError: true,
1551
+ });
1552
+ const block = buildThreadHistory(messages, channel, msg);
1553
+ if (!block) return;
1554
+ if (msg.originalContent === undefined) msg.originalContent = msg.content;
1555
+ msg.content = block + (msg.content || '');
1556
+ msg._threadHistoryApplied = true;
1557
+ } catch (e) {
1558
+ this._log(`Recent thread history unavailable for ${channel}: ${e.message}`);
1559
+ }
1292
1560
  }
1293
1561
 
1294
1562
  // ------------------------------------------------------------------
@@ -1492,7 +1760,7 @@ class BaseAdapter {
1492
1760
  async _autoTitleChannel(channel, content) {
1493
1761
  if (this._titledSessions.has(channel)) return;
1494
1762
  this._titledSessions.add(channel);
1495
- const title = generateSessionTitle(content);
1763
+ const title = generateSessionTitle(this._channelRequestContent[channel] ?? content);
1496
1764
  if (!title) return;
1497
1765
  try {
1498
1766
  const info = await this.client.getSession(this.workspaceId, channel, this.token);
@@ -1555,13 +1823,27 @@ class BaseAdapter {
1555
1823
  * structured field: an earlier attempt matched the word "completed" in the
1556
1824
  * message text and fired on every bash for-loop that ended in `done`.
1557
1825
  */
1558
- async sendResponse(channel, content) {
1826
+ async sendResponse(channel, content, { statusKind = 'completed' } = {}) {
1559
1827
  if (this._isMuted(channel)) return;
1560
1828
  try {
1829
+ let attachments = [];
1830
+ if (!(this.disabledModules || new Set()).has('files') && channel && !channel.startsWith('dm:') && this.client.uploadFile) {
1831
+ const published = await publishResponseArtifacts({
1832
+ content,
1833
+ workingDir: this.workingDir,
1834
+ upload: (filename, bytes, contentType) => this.client.uploadFile(this.workspaceId, this.token, filename, bytes, {
1835
+ contentType, source: `openagents:${this.agentName}`, channelName: channel, visibility: 'inherit',
1836
+ }),
1837
+ onError: () => this._log('Could not attach a linked document; leaving its original link in the reply.'),
1838
+ });
1839
+ content = published.content;
1840
+ attachments = published.attachments;
1841
+ }
1561
1842
  await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
1843
+ attachments,
1562
1844
  senderType: 'agent',
1563
1845
  senderName: this.agentName,
1564
- metadata: { status_kind: 'completed' },
1846
+ metadata: { status_kind: statusKind, ...(this._channelTriggerId?.[channel] ? { in_reply_to: this._channelTriggerId[channel] } : {}) },
1565
1847
  sessionId: this._sessionId,
1566
1848
  });
1567
1849
  } catch (e) {
@@ -1701,3 +1983,7 @@ class BaseAdapter {
1701
1983
  module.exports = BaseAdapter;
1702
1984
  module.exports.STALE_MESSAGE_MAX_AGE_MS = STALE_MESSAGE_MAX_AGE_MS;
1703
1985
  module.exports.CURSOR_RESUME_MAX_AGE_MS = CURSOR_RESUME_MAX_AGE_MS;
1986
+ module.exports.PRESENCE_HEARTBEAT_DEBOUNCE_MS = PRESENCE_HEARTBEAT_DEBOUNCE_MS;
1987
+ module.exports.SHARED_CONTEXT_TTL_MS = SHARED_CONTEXT_TTL_MS;
1988
+ module.exports.buildPresenceFields = buildPresenceFields;
1989
+ module.exports.buildSharedContextBlock = buildSharedContextBlock;