@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.
- package/README.md +32 -2
- package/package.json +1 -1
- package/registry.json +9 -3
- package/src/adapters/antigravity-stream.js +65 -5
- package/src/adapters/base.js +332 -35
- package/src/adapters/claude.js +152 -21
- package/src/adapters/codex.js +37 -3
- package/src/adapters/cursor-node-entry.js +64 -0
- package/src/adapters/cursor.js +31 -1
- package/src/adapters/failure-streak.js +89 -0
- package/src/adapters/hermes.js +354 -58
- package/src/adapters/opencode.js +25 -3
- package/src/adapters/response-artifacts.js +72 -0
- package/src/adapters/thread-history.js +49 -0
- package/src/adapters/workspace-prompt.js +102 -12
- package/src/cli-models.js +244 -0
- package/src/cli.js +69 -13
- package/src/config.js +13 -3
- package/src/daemon.js +183 -50
- package/src/index.js +10 -1
- package/src/installer.js +413 -16
- package/src/log-writer.js +351 -0
- package/src/mcp-server.js +138 -9
- package/src/node-config.js +6 -4
- package/src/postinstall-stub.js +69 -0
- package/src/tools-v11/brief.js +97 -0
- package/src/tools-v11/index.js +28 -0
- package/src/tools-v11/presence-handoff.js +199 -0
- package/src/workspace-client.js +274 -32
package/src/adapters/base.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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.
|
|
508
|
-
this.
|
|
509
|
-
|
|
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.
|
|
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.
|
|
661
|
+
await this._withinControlDeadline(
|
|
662
|
+
this._onControlAction(action, payload),
|
|
663
|
+
this._CONTROL_ACTION_DEADLINE_MS, `control action '${action}'`,
|
|
664
|
+
);
|
|
529
665
|
}
|
|
530
666
|
}
|
|
531
|
-
|
|
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
|
-
|
|
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;
|