@openagents-org/agent-launcher 0.2.193 → 0.2.194
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +277 -9
- 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/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 +1 -0
- package/src/daemon.js +153 -15
- package/src/index.js +10 -1
- package/src/installer.js +413 -16
- 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 +230 -18
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
|
-
|
|
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
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": "
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
213
|
-
|
|
214
|
-
|
|
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
|
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');
|
|
@@ -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
|
|
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,7 @@ 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 = {};
|
|
165
242
|
// Channels a user stopped whose next turn has not started yet. Everything
|
|
166
243
|
// the send* helpers would post there is dropped: output a killed run was
|
|
167
244
|
// still flushing lands after the stop notice otherwise, and the workspace
|
|
@@ -194,6 +271,14 @@ class BaseAdapter {
|
|
|
194
271
|
// (Claude) fetch directly instead and leave it false.
|
|
195
272
|
this._usesPinnedContext = false;
|
|
196
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 = {};
|
|
197
282
|
// Wall-clock timestamp of adapter init, used by the `status` control
|
|
198
283
|
// action to report uptime back to the channel. Reset on reinstantiation
|
|
199
284
|
// (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
|
|
@@ -210,6 +295,9 @@ class BaseAdapter {
|
|
|
210
295
|
// Runs of identical background failures (see failure-streak.js).
|
|
211
296
|
this._pollFailures = new FailureStreak();
|
|
212
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();
|
|
213
301
|
}
|
|
214
302
|
|
|
215
303
|
// ------------------------------------------------------------------
|
|
@@ -370,6 +458,7 @@ class BaseAdapter {
|
|
|
370
458
|
this._running = false;
|
|
371
459
|
this._wakeControlPoller();
|
|
372
460
|
clearInterval(heartbeatInterval);
|
|
461
|
+
if (this._presenceHeartbeatTimer) { clearTimeout(this._presenceHeartbeatTimer); this._presenceHeartbeatTimer = null; }
|
|
373
462
|
try { await controlPoller; } catch {}
|
|
374
463
|
try {
|
|
375
464
|
await this.client.disconnect(this.workspaceId, this.agentName, this.token);
|
|
@@ -461,9 +550,30 @@ class BaseAdapter {
|
|
|
461
550
|
// Heartbeat
|
|
462
551
|
// ------------------------------------------------------------------
|
|
463
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
|
+
|
|
464
574
|
async _heartbeat() {
|
|
465
575
|
try {
|
|
466
|
-
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());
|
|
467
577
|
this._heartbeatFailStreak = 0;
|
|
468
578
|
const recovered = this._heartbeatFailures.succeed();
|
|
469
579
|
if (recovered) {
|
|
@@ -518,10 +628,16 @@ class BaseAdapter {
|
|
|
518
628
|
|
|
519
629
|
async _pollControl() {
|
|
520
630
|
try {
|
|
521
|
-
const events = await this.
|
|
522
|
-
this.
|
|
523
|
-
|
|
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',
|
|
524
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;
|
|
525
641
|
for (const ev of events) {
|
|
526
642
|
if (ev.id) this._lastControlId = ev.id;
|
|
527
643
|
const payload = ev.payload || {};
|
|
@@ -537,12 +653,48 @@ class BaseAdapter {
|
|
|
537
653
|
// Handled here for every adapter, never by an override: a stop has
|
|
538
654
|
// to stay inside the channel it names, and each adapter used to
|
|
539
655
|
// decide that for itself — several stopped every channel.
|
|
540
|
-
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
|
+
);
|
|
541
660
|
} else {
|
|
542
|
-
await this.
|
|
661
|
+
await this._withinControlDeadline(
|
|
662
|
+
this._onControlAction(action, payload),
|
|
663
|
+
this._CONTROL_ACTION_DEADLINE_MS, `control action '${action}'`,
|
|
664
|
+
);
|
|
543
665
|
}
|
|
544
666
|
}
|
|
545
|
-
|
|
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
|
+
]);
|
|
546
698
|
}
|
|
547
699
|
|
|
548
700
|
/**
|
|
@@ -1216,6 +1368,7 @@ class BaseAdapter {
|
|
|
1216
1368
|
msg._queueId = queueId;
|
|
1217
1369
|
msg._acceptedGeneration = this._stopGenerationFor(channel);
|
|
1218
1370
|
this._channelQueues[channel].push(msg);
|
|
1371
|
+
this._schedulePresenceHeartbeat();
|
|
1219
1372
|
try {
|
|
1220
1373
|
await this.sendStatus(channel, 'message queued — will process after current task', {
|
|
1221
1374
|
queued_message: (msg.content || '').slice(0, 200),
|
|
@@ -1246,6 +1399,7 @@ class BaseAdapter {
|
|
|
1246
1399
|
|
|
1247
1400
|
async _channelWorker(channel, msg) {
|
|
1248
1401
|
this._channelBusy.add(channel);
|
|
1402
|
+
this._schedulePresenceHeartbeat();
|
|
1249
1403
|
// Messages an adapter queues for itself (Claude's todo nudge) carry no
|
|
1250
1404
|
// generation; they belong to the turn that queued them.
|
|
1251
1405
|
const startGeneration = msg._acceptedGeneration !== undefined
|
|
@@ -1265,6 +1419,16 @@ class BaseAdapter {
|
|
|
1265
1419
|
}
|
|
1266
1420
|
// Pinned entries may have changed while this message waited.
|
|
1267
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);
|
|
1268
1432
|
// Checked after the round trips above, which a stop can land inside:
|
|
1269
1433
|
// a message from before the stop never reaches the CLI.
|
|
1270
1434
|
if (generation !== this._stopGenerationFor(channel)) {
|
|
@@ -1282,6 +1446,7 @@ class BaseAdapter {
|
|
|
1282
1446
|
try { await this.sendError(channel, `Agent error: ${e.message}`); } catch {}
|
|
1283
1447
|
}
|
|
1284
1448
|
delete this._channelRunGeneration[channel];
|
|
1449
|
+
delete this._channelRequestContent[channel];
|
|
1285
1450
|
first = false;
|
|
1286
1451
|
|
|
1287
1452
|
const queue = this._channelQueues[channel];
|
|
@@ -1289,6 +1454,91 @@ class BaseAdapter {
|
|
|
1289
1454
|
}
|
|
1290
1455
|
if (dropped) this._log(`Stop in ${channel} — dropped ${dropped} message(s) accepted before it`);
|
|
1291
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
|
+
}
|
|
1292
1542
|
}
|
|
1293
1543
|
|
|
1294
1544
|
// ------------------------------------------------------------------
|
|
@@ -1492,7 +1742,7 @@ class BaseAdapter {
|
|
|
1492
1742
|
async _autoTitleChannel(channel, content) {
|
|
1493
1743
|
if (this._titledSessions.has(channel)) return;
|
|
1494
1744
|
this._titledSessions.add(channel);
|
|
1495
|
-
const title = generateSessionTitle(content);
|
|
1745
|
+
const title = generateSessionTitle(this._channelRequestContent[channel] ?? content);
|
|
1496
1746
|
if (!title) return;
|
|
1497
1747
|
try {
|
|
1498
1748
|
const info = await this.client.getSession(this.workspaceId, channel, this.token);
|
|
@@ -1558,7 +1808,21 @@ class BaseAdapter {
|
|
|
1558
1808
|
async sendResponse(channel, content) {
|
|
1559
1809
|
if (this._isMuted(channel)) return;
|
|
1560
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
|
+
}
|
|
1561
1824
|
await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
|
|
1825
|
+
attachments,
|
|
1562
1826
|
senderType: 'agent',
|
|
1563
1827
|
senderName: this.agentName,
|
|
1564
1828
|
metadata: { status_kind: 'completed' },
|
|
@@ -1701,3 +1965,7 @@ class BaseAdapter {
|
|
|
1701
1965
|
module.exports = BaseAdapter;
|
|
1702
1966
|
module.exports.STALE_MESSAGE_MAX_AGE_MS = STALE_MESSAGE_MAX_AGE_MS;
|
|
1703
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;
|