@openagents-org/agent-launcher 0.2.192 → 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.
@@ -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');
@@ -35,9 +37,69 @@ const {
35
37
  classifyHeartbeatError,
36
38
  httpStatusOf,
37
39
  } = require('./health-status');
40
+ const { FailureStreak, isTransientNetworkError, formatDuration } = require('./failure-streak');
38
41
 
39
42
  const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
40
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
+
41
103
  // ── Poll-cursor persistence ──
42
104
  // An adapter that starts by jumping to the head of the event stream silently
43
105
  // drops every message that arrived while it was down (crash, update, user
@@ -60,6 +122,18 @@ const CURSOR_RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000;
60
122
  // heartbeat_failed up to the daemon. A success resets the streak immediately.
61
123
  const HEARTBEAT_ERROR_THRESHOLD = 2;
62
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
+
63
137
  /**
64
138
  * Model variables whose name does not follow `<TYPE>_MODEL`. Only the ones an
65
139
  * adapter really reads — see `modelLabel`, which tries `<TYPE>_MODEL` first
@@ -104,7 +178,7 @@ class BaseAdapter {
104
178
  * @param {string} opts.agentName
105
179
  * @param {string} [opts.endpoint]
106
180
  */
107
- constructor({ workspaceId, channelName, token, agentName, endpoint, agentEnv, agentType, workingDir, onStatus }) {
181
+ constructor({ workspaceId, channelName, token, agentName, endpoint, agentEnv, agentType, workingDir, onStatus, logSink }) {
108
182
  this.workspaceId = workspaceId;
109
183
  this.channelName = channelName;
110
184
  this.token = token;
@@ -128,7 +202,8 @@ class BaseAdapter {
128
202
  // Set when the user explicitly stops this adapter (vs an error/revoke), so a
129
203
  // clean stop is never mislabeled as an error.
130
204
  this._stopRequested = false;
131
- 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 });
132
207
  this._lastEventId = null;
133
208
  this._cursorFile = this._cursorFilePath();
134
209
  this._lastPersistedCursor = null;
@@ -144,6 +219,8 @@ class BaseAdapter {
144
219
  this.workspaceModel = null;
145
220
  this._lastControlId = null;
146
221
  this._controlWake = null;
222
+ this._CONTROL_POLL_DEADLINE_MS = CONTROL_POLL_DEADLINE_MS;
223
+ this._CONTROL_ACTION_DEADLINE_MS = CONTROL_ACTION_DEADLINE_MS;
147
224
  // Per-channel task tracking for parallel execution
148
225
  this._channelBusy = new Set();
149
226
  this._channelQueues = {};
@@ -161,6 +238,7 @@ class BaseAdapter {
161
238
  // adapter can tell "a stop landed while I was preparing this turn" from
162
239
  // "this turn started after the stop and is meant to run".
163
240
  this._channelRunGeneration = {};
241
+ this._channelRequestContent = {};
164
242
  // Channels a user stopped whose next turn has not started yet. Everything
165
243
  // the send* helpers would post there is dropped: output a killed run was
166
244
  // still flushing lands after the stop notice otherwise, and the workspace
@@ -193,15 +271,33 @@ class BaseAdapter {
193
271
  // (Claude) fetch directly instead and leave it false.
194
272
  this._usesPinnedContext = false;
195
273
  this._pinnedContext = {}; // channel → { decisions, glossary }
274
+ // ── v1.1 M3/M4 ──
275
+ // Debounce handle for the busy↔idle presence heartbeat.
276
+ this._presenceHeartbeatTimer = null;
277
+ // channel → { fetchedAt, requesterEmail, ctx } from /shared-context.
278
+ this._sharedContext = {};
279
+ // channel → email of the person whose message is being handled (so the
280
+ // per-run MCP server can tell the owner who asked).
281
+ this._requesterEmail = {};
196
282
  // Wall-clock timestamp of adapter init, used by the `status` control
197
283
  // action to report uptime back to the channel. Reset on reinstantiation
198
284
  // (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
199
285
  // restart" rather than the long-running daemon's process uptime.
200
286
  this._startedAt = Date.now();
201
- this._log = (msg) => {
287
+ // Inside the daemon, `logSink` routes lines through its daemon.log writer
288
+ // (batching, repeat suppression, rotation). Standalone, they go to stdout.
289
+ const sink = typeof logSink === 'function' ? logSink : null;
290
+ this._log = (msg, level = 'info') => {
291
+ if (sink) return sink(level, msg);
202
292
  const ts = new Date().toISOString();
203
- console.log(`${ts} INFO adapter [${this.agentName}]: ${msg}`);
293
+ console.log(`${ts} ${String(level).toUpperCase()} adapter [${this.agentName}]: ${msg}`);
204
294
  };
295
+ // Runs of identical background failures (see failure-streak.js).
296
+ this._pollFailures = new FailureStreak();
297
+ this._heartbeatFailures = new FailureStreak();
298
+ // The control poller (Stop, mode changes) fails silently otherwise: its
299
+ // errors are caught so the loop keeps going, and nothing else logs them.
300
+ this._controlFailures = new FailureStreak();
205
301
  }
206
302
 
207
303
  // ------------------------------------------------------------------
@@ -337,7 +433,7 @@ class BaseAdapter {
337
433
  this._log(`Synced model from workspace: ${this.workspaceModel}`);
338
434
  }
339
435
  } catch (e) {
340
- this._log(`Warning: skill sync failed (non-fatal): ${e.message}`);
436
+ this._log(`Warning: skill sync failed (non-fatal): ${e.message}`, 'warn');
341
437
  }
342
438
 
343
439
  // Fast-path operations (control-event cursor + heartbeat + control poll)
@@ -352,7 +448,7 @@ class BaseAdapter {
352
448
  try {
353
449
  // Send initial heartbeat
354
450
  try { await this._heartbeat(); } catch (e) {
355
- this._log(`Heartbeat failed (non-fatal): ${e.message}`);
451
+ this._log(`Heartbeat failed (non-fatal): ${e.message}`, 'warn');
356
452
  }
357
453
  // Slow path: only the message-poll loop waits for this.
358
454
  await this._skipExistingEvents();
@@ -362,6 +458,7 @@ class BaseAdapter {
362
458
  this._running = false;
363
459
  this._wakeControlPoller();
364
460
  clearInterval(heartbeatInterval);
461
+ if (this._presenceHeartbeatTimer) { clearTimeout(this._presenceHeartbeatTimer); this._presenceHeartbeatTimer = null; }
365
462
  try { await controlPoller; } catch {}
366
463
  try {
367
464
  await this.client.disconnect(this.workspaceId, this.agentName, this.token);
@@ -412,7 +509,7 @@ class BaseAdapter {
412
509
  } catch (e) {
413
510
  if (!this._warnedCursorPersist) {
414
511
  this._warnedCursorPersist = true;
415
- this._log(`Could not persist poll cursor (${e.message}) — a restart will skip messages that arrive while offline`);
512
+ this._log(`Could not persist poll cursor (${e.message}) — a restart will skip messages that arrive while offline`, 'warn');
416
513
  }
417
514
  }
418
515
  }
@@ -453,14 +550,39 @@ class BaseAdapter {
453
550
  // Heartbeat
454
551
  // ------------------------------------------------------------------
455
552
 
553
+ /** Presence fields for the next heartbeat (see buildPresenceFields). */
554
+ presenceFields() {
555
+ return buildPresenceFields(this._channelBusy, this._channelQueues);
556
+ }
557
+
558
+ /**
559
+ * A channel just went busy or idle (or its queue changed): send a heartbeat
560
+ * soon so the roster flips promptly instead of on the next 30s tick. Bursts
561
+ * collapse into one send.
562
+ */
563
+ _schedulePresenceHeartbeat() {
564
+ if (!this._running) return;
565
+ if (this._presenceHeartbeatTimer) return;
566
+ this._presenceHeartbeatTimer = setTimeout(() => {
567
+ this._presenceHeartbeatTimer = null;
568
+ if (!this._running) return;
569
+ this._heartbeat().catch(() => {});
570
+ }, PRESENCE_HEARTBEAT_DEBOUNCE_MS);
571
+ if (typeof this._presenceHeartbeatTimer.unref === 'function') this._presenceHeartbeatTimer.unref();
572
+ }
573
+
456
574
  async _heartbeat() {
457
575
  try {
458
- await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId);
576
+ await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId, this.presenceFields());
459
577
  this._heartbeatFailStreak = 0;
578
+ const recovered = this._heartbeatFailures.succeed();
579
+ if (recovered) {
580
+ this._log(`Workspace heartbeat recovered after ${recovered.count} unsuccessful attempt(s) over ${formatDuration(recovered.ms)}`);
581
+ }
460
582
  this._reportStatus(null); // alive → clear any prior connectivity error
461
583
  } catch (e) {
462
584
  if (e instanceof SessionRevokedError) {
463
- this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`);
585
+ this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`, 'warn');
464
586
  // Terminal (not a user stop): record so the daemon can show why it ended.
465
587
  this._setExitInfo(REASON.SESSION_REVOKED, 'Workspace session revoked — another client joined with the same agent name');
466
588
  this._reportStatus(REASON.SESSION_REVOKED, 'Workspace session revoked');
@@ -471,7 +593,9 @@ class BaseAdapter {
471
593
  // single transient blip (or an expected brief reconnect) isn't mislabeled.
472
594
  this._heartbeatFailStreak++;
473
595
  const { reason, message } = classifyHeartbeatError(e);
474
- this._log(`${message} (consecutive failures: ${this._heartbeatFailStreak})`);
596
+ if (this._heartbeatFailures.fail()) {
597
+ this._log(`${message} (consecutive failures: ${this._heartbeatFailStreak})`, 'warn');
598
+ }
475
599
  if (this._heartbeatFailStreak >= HEARTBEAT_ERROR_THRESHOLD) {
476
600
  this._reportStatus(reason, message);
477
601
  }
@@ -504,10 +628,16 @@ class BaseAdapter {
504
628
 
505
629
  async _pollControl() {
506
630
  try {
507
- const events = await this.client.pollControl(
508
- this.workspaceId, this.agentName, this.token,
509
- { after: this._lastControlId }
631
+ const events = await this._withinControlDeadline(
632
+ this.client.pollControl(
633
+ this.workspaceId, this.agentName, this.token,
634
+ { after: this._lastControlId }
635
+ ),
636
+ this._CONTROL_POLL_DEADLINE_MS, 'control poll',
510
637
  );
638
+ // A poll abandoned at its deadline delivered nothing; the cursor has
639
+ // not moved, so the next one asks for the same events again.
640
+ if (events === CONTROL_DEADLINE_HIT) return;
511
641
  for (const ev of events) {
512
642
  if (ev.id) this._lastControlId = ev.id;
513
643
  const payload = ev.payload || {};
@@ -523,12 +653,48 @@ class BaseAdapter {
523
653
  // Handled here for every adapter, never by an override: a stop has
524
654
  // to stay inside the channel it names, and each adapter used to
525
655
  // decide that for itself — several stopped every channel.
526
- await this._handleUserStop(payload.channel || null);
656
+ await this._withinControlDeadline(
657
+ this._handleUserStop(payload.channel || null),
658
+ this._CONTROL_ACTION_DEADLINE_MS, `stop for ${payload.channel || 'every channel'}`,
659
+ );
527
660
  } else {
528
- await this._onControlAction(action, payload);
661
+ await this._withinControlDeadline(
662
+ this._onControlAction(action, payload),
663
+ this._CONTROL_ACTION_DEADLINE_MS, `control action '${action}'`,
664
+ );
529
665
  }
530
666
  }
531
- } catch {}
667
+ const recovered = this._controlFailures.succeed();
668
+ if (recovered) {
669
+ this._log(`Control poll recovered after ${recovered.count} unsuccessful attempt(s) over ${formatDuration(recovered.ms)}`);
670
+ }
671
+ } catch (e) {
672
+ // Keep polling, but leave a trace: a control channel that keeps failing
673
+ // means Stop and mode changes are not reaching this agent.
674
+ if (this._controlFailures.fail()) {
675
+ const count = this._controlFailures.count;
676
+ const streak = count > 1 ? ` (consecutive failures: ${count})` : '';
677
+ this._log(`Control poll failed: ${e && e.message}${streak}`, isTransientNetworkError(e) ? 'warn' : 'error');
678
+ }
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Await `work` for at most `ms`. Resolves to CONTROL_DEADLINE_HIT when the
684
+ * deadline wins; `work` is left to finish, or not, on its own.
685
+ */
686
+ _withinControlDeadline(work, ms, what) {
687
+ let timer;
688
+ const deadline = new Promise((resolve) => {
689
+ timer = setTimeout(() => {
690
+ this._log(`Control poller: ${what} still pending after ${Math.round(ms / 1000)}s — polling on without it`, 'warn');
691
+ resolve(CONTROL_DEADLINE_HIT);
692
+ }, ms);
693
+ });
694
+ return Promise.race([
695
+ Promise.resolve(work).finally(() => clearTimeout(timer)),
696
+ deadline,
697
+ ]);
532
698
  }
533
699
 
534
700
  /**
@@ -593,7 +759,7 @@ class BaseAdapter {
593
759
  }
594
760
  if (!channel) {
595
761
  try { await this._stopSharedWork(); } catch (e) {
596
- this._log(`Stop: shared work did not stop cleanly: ${e && e.message ? e.message : e}`);
762
+ this._log(`Stop: shared work did not stop cleanly: ${e && e.message ? e.message : e}`, 'warn');
597
763
  }
598
764
  }
599
765
  for (const ch of channels) {
@@ -601,7 +767,7 @@ class BaseAdapter {
601
767
  try {
602
768
  outcome = await this._stopChannelWork(ch);
603
769
  } catch (e) {
604
- this._log(`Stop: ${ch} did not stop cleanly: ${e && e.message ? e.message : e}`);
770
+ this._log(`Stop: ${ch} did not stop cleanly: ${e && e.message ? e.message : e}`, 'warn');
605
771
  outcome = 'failed';
606
772
  }
607
773
  // The pressed channel is always answered, so its UI settles even when
@@ -788,7 +954,7 @@ class BaseAdapter {
788
954
  skillId, state: 'installing',
789
955
  });
790
956
  } catch (e) {
791
- this._log(`skill.install: could not report 'installing' (non-fatal): ${e && e.message ? e.message : e}`);
957
+ this._log(`skill.install: could not report 'installing' (non-fatal): ${e && e.message ? e.message : e}`, 'warn');
792
958
  }
793
959
 
794
960
  try {
@@ -822,19 +988,19 @@ class BaseAdapter {
822
988
  skillId, state: 'installed', path: result.path, partial: result.partial === true,
823
989
  });
824
990
  } catch (e) {
825
- this._log(`skill.install: installed on disk but failed to report 'installed': ${e && e.message ? e.message : e}`);
991
+ this._log(`skill.install: installed on disk but failed to report 'installed': ${e && e.message ? e.message : e}`, 'warn');
826
992
  }
827
993
  this._log(`skill.install: SUCCESS "${skillId}" → ${result.path}${result.partial ? ' (partial)' : ''}`);
828
994
  await this._onSkillsChanged();
829
995
  } catch (e) {
830
996
  const msg = e && e.message ? e.message : String(e);
831
- this._log(`skill.install: FAILED "${skillId}": ${msg}`);
997
+ this._log(`skill.install: FAILED "${skillId}": ${msg}`, 'error');
832
998
  try {
833
999
  await this.client.reportSkillStatus(this.workspaceId, this.agentName, this.token, {
834
1000
  skillId, state: 'failed', error: msg,
835
1001
  });
836
1002
  } catch (e2) {
837
- this._log(`skill.install: also failed to report 'failed': ${e2 && e2.message ? e2.message : e2}`);
1003
+ this._log(`skill.install: also failed to report 'failed': ${e2 && e2.message ? e2.message : e2}`, 'warn');
838
1004
  }
839
1005
  }
840
1006
  }
@@ -863,12 +1029,12 @@ class BaseAdapter {
863
1029
  skillId, state: 'uninstalled',
864
1030
  });
865
1031
  } catch (e) {
866
- this._log(`skill.uninstall: failed to report status: ${e && e.message ? e.message : e}`);
1032
+ this._log(`skill.uninstall: failed to report status: ${e && e.message ? e.message : e}`, 'warn');
867
1033
  }
868
1034
  await this._onSkillsChanged();
869
1035
  } catch (e) {
870
1036
  const msg = e && e.message ? e.message : String(e);
871
- this._log(`skill.uninstall: FAILED "${skillId}": ${msg}`);
1037
+ this._log(`skill.uninstall: FAILED "${skillId}": ${msg}`, 'error');
872
1038
  }
873
1039
  }
874
1040
 
@@ -923,7 +1089,7 @@ class BaseAdapter {
923
1089
  sessionId: this._sessionId,
924
1090
  });
925
1091
  } catch (e) {
926
- this._log(`Status: failed to post: ${e && e.message ? e.message : e}`);
1092
+ this._log(`Status: failed to post: ${e && e.message ? e.message : e}`, 'warn');
927
1093
  }
928
1094
  }
929
1095
 
@@ -950,7 +1116,7 @@ class BaseAdapter {
950
1116
  (r) => r.created_by === prefixed || r.created_by === this.agentName,
951
1117
  );
952
1118
  } catch (e) {
953
- this._log(`Routines: failed to list: ${e && e.message ? e.message : e}`);
1119
+ this._log(`Routines: failed to list: ${e && e.message ? e.message : e}`, 'warn');
954
1120
  try {
955
1121
  await this.client.sendMessage(
956
1122
  this.workspaceId, channel, this.token,
@@ -991,7 +1157,7 @@ class BaseAdapter {
991
1157
  sessionId: this._sessionId,
992
1158
  });
993
1159
  } catch (e) {
994
- this._log(`Routines: failed to post: ${e && e.message ? e.message : e}`);
1160
+ this._log(`Routines: failed to post: ${e && e.message ? e.message : e}`, 'warn');
995
1161
  }
996
1162
  }
997
1163
 
@@ -1049,11 +1215,26 @@ class BaseAdapter {
1049
1215
  rawCursor = result.cursor;
1050
1216
  composingActive = !!result.composing;
1051
1217
  this._pollAuthFailStreak = 0;
1218
+ const recovered = this._pollFailures.succeed();
1219
+ if (recovered) {
1220
+ this._log(`Poll recovered after ${recovered.count} unsuccessful attempt(s) over ${formatDuration(recovered.ms)}`);
1221
+ }
1052
1222
  if (pollCount <= 3 || pollCount % 20 === 0) {
1053
1223
  this._log(`Poll #${pollCount}: ${messages.length} messages, cursor=${rawCursor || 'none'}${composingActive ? ' composing' : ''}`);
1054
1224
  }
1055
1225
  } catch (e) {
1056
- this._log(`Poll #${pollCount} failed: ${e.message} \nStack: ${e.stack}`);
1226
+ // A workspace that is down or slow fails every poll until it is back;
1227
+ // log the streak, not every attempt (see failure-streak.js).
1228
+ if (this._pollFailures.fail()) {
1229
+ const count = this._pollFailures.count;
1230
+ const streak = count > 1 ? ` (consecutive failures: ${count})` : '';
1231
+ if (isTransientNetworkError(e)) {
1232
+ this._log(`Poll #${pollCount} failed: ${e.message}${streak} — retrying every 5s`, 'warn');
1233
+ } else {
1234
+ const stack = count === 1 && e && e.stack ? `\nStack: ${e.stack}` : '';
1235
+ this._log(`Poll #${pollCount} failed: ${e && e.message}${streak}${stack}`, 'error');
1236
+ }
1237
+ }
1057
1238
  // A rejected credential never heals by retrying with the same token —
1058
1239
  // it means the device was re-paired/unpaired and this adapter is
1059
1240
  // running on a revoked one. After a few consecutive rejections (not
@@ -1187,6 +1368,7 @@ class BaseAdapter {
1187
1368
  msg._queueId = queueId;
1188
1369
  msg._acceptedGeneration = this._stopGenerationFor(channel);
1189
1370
  this._channelQueues[channel].push(msg);
1371
+ this._schedulePresenceHeartbeat();
1190
1372
  try {
1191
1373
  await this.sendStatus(channel, 'message queued — will process after current task', {
1192
1374
  queued_message: (msg.content || '').slice(0, 200),
@@ -1217,6 +1399,7 @@ class BaseAdapter {
1217
1399
 
1218
1400
  async _channelWorker(channel, msg) {
1219
1401
  this._channelBusy.add(channel);
1402
+ this._schedulePresenceHeartbeat();
1220
1403
  // Messages an adapter queues for itself (Claude's todo nudge) carry no
1221
1404
  // generation; they belong to the turn that queued them.
1222
1405
  const startGeneration = msg._acceptedGeneration !== undefined
@@ -1236,6 +1419,16 @@ class BaseAdapter {
1236
1419
  }
1237
1420
  // Pinned entries may have changed while this message waited.
1238
1421
  await this._prefetchPinnedContext(channel);
1422
+ this._channelRequestContent[channel] = current.originalContent ?? current.content;
1423
+ if (channel && !channel.startsWith('dm:') && current.messageId
1424
+ && typeof this.client?.getRecentMessages === 'function') {
1425
+ await this._applyThreadHistory(channel, current);
1426
+ }
1427
+ // A teammate's request runs under the owner-reviewed shared
1428
+ // instructions (v1.1 M4); the block is prepended to the message.
1429
+ // Gated synchronously so messages it cannot apply to (agents,
1430
+ // anonymous humans, system) add no extra tick before the stop check.
1431
+ if (this._sharedContextCandidate(current)) await this._applySharedContext(channel, current);
1239
1432
  // Checked after the round trips above, which a stop can land inside:
1240
1433
  // a message from before the stop never reaches the CLI.
1241
1434
  if (generation !== this._stopGenerationFor(channel)) {
@@ -1249,10 +1442,11 @@ class BaseAdapter {
1249
1442
  await this._handleMessage(current);
1250
1443
  }
1251
1444
  } catch (e) {
1252
- this._log(`Error ${first ? 'in channel worker' : 'processing queued message'} for ${channel}: ${e.message}`);
1445
+ this._log(`Error ${first ? 'in channel worker' : 'processing queued message'} for ${channel}: ${e.message}`, 'error');
1253
1446
  try { await this.sendError(channel, `Agent error: ${e.message}`); } catch {}
1254
1447
  }
1255
1448
  delete this._channelRunGeneration[channel];
1449
+ delete this._channelRequestContent[channel];
1256
1450
  first = false;
1257
1451
 
1258
1452
  const queue = this._channelQueues[channel];
@@ -1260,6 +1454,91 @@ class BaseAdapter {
1260
1454
  }
1261
1455
  if (dropped) this._log(`Stop in ${channel} — dropped ${dropped} message(s) accepted before it`);
1262
1456
  this._channelBusy.delete(channel);
1457
+ delete this._requesterEmail[channel];
1458
+ this._schedulePresenceHeartbeat();
1459
+ }
1460
+
1461
+ // ------------------------------------------------------------------
1462
+ // Shared context for teammates' requests (v1.1 M4)
1463
+ // ------------------------------------------------------------------
1464
+
1465
+ /** The email of the person whose request this channel is running, if known. */
1466
+ requesterEmailFor(channel) {
1467
+ return this._requesterEmail[channel] || null;
1468
+ }
1469
+
1470
+ /**
1471
+ * Fetch (cached 60s per channel + requester) the owner-reviewed shared
1472
+ * context for this run. Returns the /shared-context payload or null.
1473
+ */
1474
+ async _fetchSharedContext(channel, requesterEmail) {
1475
+ const cached = this._sharedContext[channel];
1476
+ const now = Date.now();
1477
+ if (cached && cached.requesterEmail === requesterEmail && (now - cached.fetchedAt) < SHARED_CONTEXT_TTL_MS) {
1478
+ return cached.ctx;
1479
+ }
1480
+ if (!this.client || typeof this.client.getSharedContext !== 'function') return null;
1481
+ try {
1482
+ const ctx = await this.client.getSharedContext(this.workspaceId, this.token, this.agentName, {
1483
+ requesterEmail, channel,
1484
+ });
1485
+ this._sharedContext[channel] = { fetchedAt: now, requesterEmail, ctx };
1486
+ return ctx;
1487
+ } catch (e) {
1488
+ this._log(`Shared-context fetch failed (${e && e.message ? e.message : e}) — running without it`);
1489
+ // Keep a stale-but-recent answer rather than flip-flopping on a blip.
1490
+ return cached && cached.requesterEmail === requesterEmail ? cached.ctx : null;
1491
+ }
1492
+ }
1493
+
1494
+ /**
1495
+ * When the triggering message comes from an identified person, look up the
1496
+ * shared context and, if it applies, prepend the instruction block to the
1497
+ * message the adapter will turn into its prompt. Records the requester for
1498
+ * the per-run MCP server. Never throws.
1499
+ */
1500
+ _sharedContextEmail(msg) {
1501
+ if (!msg || msg.senderType !== 'human') return null;
1502
+ const raw = msg.senderEmail || (msg.metadata && msg.metadata.sender_email) || '';
1503
+ const email = String(raw).trim().toLowerCase();
1504
+ return email && email.includes('@') ? email : null;
1505
+ }
1506
+
1507
+ _sharedContextCandidate(msg) {
1508
+ return this._sharedContextEmail(msg) !== null;
1509
+ }
1510
+
1511
+ async _applySharedContext(channel, msg) {
1512
+ const email = this._sharedContextEmail(msg);
1513
+ if (!email) return false;
1514
+ this._requesterEmail[channel] = email;
1515
+ if (msg._sharedContextApplied) return true;
1516
+ const ctx = await this._fetchSharedContext(channel, email);
1517
+ const block = buildSharedContextBlock(ctx);
1518
+ if (!block) return false;
1519
+ if (msg.originalContent === undefined) msg.originalContent = msg.content;
1520
+ msg.content = block + '\n' + (msg.content || '');
1521
+ msg._sharedContextApplied = true;
1522
+ this._log(`Shared context applied for ${email} in ${channel} (owner: ${ctx.owner_email || '?'})`);
1523
+ return true;
1524
+ }
1525
+
1526
+ async _applyThreadHistory(channel, msg) {
1527
+ // DMs have separate peer sessions. Synthetic nudges have no event boundary.
1528
+ if (!channel || channel.startsWith('dm:') || !msg.messageId || msg._threadHistoryApplied) return;
1529
+ if (typeof this.client?.getRecentMessages !== 'function') return;
1530
+ try {
1531
+ const messages = await this.client.getRecentMessages(this.workspaceId, channel, this.token, 100, {
1532
+ before: msg.messageId, timeout: 5000, throwOnError: true,
1533
+ });
1534
+ const block = buildThreadHistory(messages, channel, msg);
1535
+ if (!block) return;
1536
+ if (msg.originalContent === undefined) msg.originalContent = msg.content;
1537
+ msg.content = block + (msg.content || '');
1538
+ msg._threadHistoryApplied = true;
1539
+ } catch (e) {
1540
+ this._log(`Recent thread history unavailable for ${channel}: ${e.message}`);
1541
+ }
1263
1542
  }
1264
1543
 
1265
1544
  // ------------------------------------------------------------------
@@ -1333,7 +1612,7 @@ class BaseAdapter {
1333
1612
  delete cache[channel];
1334
1613
  // Entry is gone — fall through to list+match below.
1335
1614
  } else {
1336
- this._log(`${label} read failed (${e.message}) — reusing last known state`);
1615
+ this._log(`${label} read failed (${e.message}) — reusing last known state`, 'warn');
1337
1616
  return { available: true, state: 'found', entryId: cachedId, title: titles[0], content: null, error: true };
1338
1617
  }
1339
1618
  }
@@ -1369,7 +1648,7 @@ class BaseAdapter {
1369
1648
  }
1370
1649
  return { available: true, state: 'found', entryId: entry.id, title: matchedTitle, content: (full && full.content) || '', error: false };
1371
1650
  } catch (e) {
1372
- this._log(`${label} fetch failed (${e.message}) — reusing last known state`);
1651
+ this._log(`${label} fetch failed (${e.message}) — reusing last known state`, 'warn');
1373
1652
  const knownId = cache[channel] || null;
1374
1653
  return { available: true, state: knownId ? 'found' : 'unknown', entryId: knownId, title: knownId ? titles[0] : null, content: null, error: true };
1375
1654
  }
@@ -1427,7 +1706,7 @@ class BaseAdapter {
1427
1706
  }
1428
1707
  this._pinnedContext[channel] = fresh;
1429
1708
  } catch (e) {
1430
- this._log(`Pinned-context fetch failed (${e && e.message ? e.message : e}) — reusing the last pinned context`);
1709
+ this._log(`Pinned-context fetch failed (${e && e.message ? e.message : e}) — reusing the last pinned context`, 'warn');
1431
1710
  }
1432
1711
  }
1433
1712
 
@@ -1463,7 +1742,7 @@ class BaseAdapter {
1463
1742
  async _autoTitleChannel(channel, content) {
1464
1743
  if (this._titledSessions.has(channel)) return;
1465
1744
  this._titledSessions.add(channel);
1466
- const title = generateSessionTitle(content);
1745
+ const title = generateSessionTitle(this._channelRequestContent[channel] ?? content);
1467
1746
  if (!title) return;
1468
1747
  try {
1469
1748
  const info = await this.client.getSession(this.workspaceId, channel, this.token);
@@ -1475,7 +1754,7 @@ class BaseAdapter {
1475
1754
  this._log(`Auto-titled channel: ${title}`);
1476
1755
  }
1477
1756
  } catch (e) {
1478
- this._log(`Failed to auto-title channel: ${e.message}`);
1757
+ this._log(`Failed to auto-title channel: ${e.message}`, 'warn');
1479
1758
  }
1480
1759
  }
1481
1760
 
@@ -1529,7 +1808,21 @@ class BaseAdapter {
1529
1808
  async sendResponse(channel, content) {
1530
1809
  if (this._isMuted(channel)) return;
1531
1810
  try {
1811
+ let attachments = [];
1812
+ if (!(this.disabledModules || new Set()).has('files') && channel && !channel.startsWith('dm:') && this.client.uploadFile) {
1813
+ const published = await publishResponseArtifacts({
1814
+ content,
1815
+ workingDir: this.workingDir,
1816
+ upload: (filename, bytes, contentType) => this.client.uploadFile(this.workspaceId, this.token, filename, bytes, {
1817
+ contentType, source: `openagents:${this.agentName}`, channelName: channel, visibility: 'inherit',
1818
+ }),
1819
+ onError: () => this._log('Could not attach a linked document; leaving its original link in the reply.'),
1820
+ });
1821
+ content = published.content;
1822
+ attachments = published.attachments;
1823
+ }
1532
1824
  await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
1825
+ attachments,
1533
1826
  senderType: 'agent',
1534
1827
  senderName: this.agentName,
1535
1828
  metadata: { status_kind: 'completed' },
@@ -1625,7 +1918,7 @@ class BaseAdapter {
1625
1918
  }
1626
1919
 
1627
1920
  _onSessionRevoked() {
1628
- this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`);
1921
+ this._log(`SESSION REVOKED: another client joined as '${this.agentName}'. Stopping adapter.`, 'warn');
1629
1922
  this._running = false;
1630
1923
  }
1631
1924
 
@@ -1672,3 +1965,7 @@ class BaseAdapter {
1672
1965
  module.exports = BaseAdapter;
1673
1966
  module.exports.STALE_MESSAGE_MAX_AGE_MS = STALE_MESSAGE_MAX_AGE_MS;
1674
1967
  module.exports.CURSOR_RESUME_MAX_AGE_MS = CURSOR_RESUME_MAX_AGE_MS;
1968
+ module.exports.PRESENCE_HEARTBEAT_DEBOUNCE_MS = PRESENCE_HEARTBEAT_DEBOUNCE_MS;
1969
+ module.exports.SHARED_CONTEXT_TTL_MS = SHARED_CONTEXT_TTL_MS;
1970
+ module.exports.buildPresenceFields = buildPresenceFields;
1971
+ module.exports.buildSharedContextBlock = buildSharedContextBlock;