@openagents-org/agent-launcher 0.2.193 → 0.2.195
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +297 -11
- package/src/adapters/claude.js +154 -23
- package/src/adapters/codex.js +40 -4
- 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,8 @@ class BaseAdapter {
|
|
|
162
238
|
// adapter can tell "a stop landed while I was preparing this turn" from
|
|
163
239
|
// "this turn started after the stop and is meant to run".
|
|
164
240
|
this._channelRunGeneration = {};
|
|
241
|
+
this._channelRequestContent = {};
|
|
242
|
+
this._channelTriggerId = {};
|
|
165
243
|
// Channels a user stopped whose next turn has not started yet. Everything
|
|
166
244
|
// the send* helpers would post there is dropped: output a killed run was
|
|
167
245
|
// still flushing lands after the stop notice otherwise, and the workspace
|
|
@@ -194,6 +272,14 @@ class BaseAdapter {
|
|
|
194
272
|
// (Claude) fetch directly instead and leave it false.
|
|
195
273
|
this._usesPinnedContext = false;
|
|
196
274
|
this._pinnedContext = {}; // channel → { decisions, glossary }
|
|
275
|
+
// ── v1.1 M3/M4 ──
|
|
276
|
+
// Debounce handle for the busy↔idle presence heartbeat.
|
|
277
|
+
this._presenceHeartbeatTimer = null;
|
|
278
|
+
// channel → { fetchedAt, requesterEmail, ctx } from /shared-context.
|
|
279
|
+
this._sharedContext = {};
|
|
280
|
+
// channel → email of the person whose message is being handled (so the
|
|
281
|
+
// per-run MCP server can tell the owner who asked).
|
|
282
|
+
this._requesterEmail = {};
|
|
197
283
|
// Wall-clock timestamp of adapter init, used by the `status` control
|
|
198
284
|
// action to report uptime back to the channel. Reset on reinstantiation
|
|
199
285
|
// (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
|
|
@@ -210,6 +296,9 @@ class BaseAdapter {
|
|
|
210
296
|
// Runs of identical background failures (see failure-streak.js).
|
|
211
297
|
this._pollFailures = new FailureStreak();
|
|
212
298
|
this._heartbeatFailures = new FailureStreak();
|
|
299
|
+
// The control poller (Stop, mode changes) fails silently otherwise: its
|
|
300
|
+
// errors are caught so the loop keeps going, and nothing else logs them.
|
|
301
|
+
this._controlFailures = new FailureStreak();
|
|
213
302
|
}
|
|
214
303
|
|
|
215
304
|
// ------------------------------------------------------------------
|
|
@@ -370,6 +459,7 @@ class BaseAdapter {
|
|
|
370
459
|
this._running = false;
|
|
371
460
|
this._wakeControlPoller();
|
|
372
461
|
clearInterval(heartbeatInterval);
|
|
462
|
+
if (this._presenceHeartbeatTimer) { clearTimeout(this._presenceHeartbeatTimer); this._presenceHeartbeatTimer = null; }
|
|
373
463
|
try { await controlPoller; } catch {}
|
|
374
464
|
try {
|
|
375
465
|
await this.client.disconnect(this.workspaceId, this.agentName, this.token);
|
|
@@ -461,9 +551,30 @@ class BaseAdapter {
|
|
|
461
551
|
// Heartbeat
|
|
462
552
|
// ------------------------------------------------------------------
|
|
463
553
|
|
|
554
|
+
/** Presence fields for the next heartbeat (see buildPresenceFields). */
|
|
555
|
+
presenceFields() {
|
|
556
|
+
return buildPresenceFields(this._channelBusy, this._channelQueues);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* A channel just went busy or idle (or its queue changed): send a heartbeat
|
|
561
|
+
* soon so the roster flips promptly instead of on the next 30s tick. Bursts
|
|
562
|
+
* collapse into one send.
|
|
563
|
+
*/
|
|
564
|
+
_schedulePresenceHeartbeat() {
|
|
565
|
+
if (!this._running) return;
|
|
566
|
+
if (this._presenceHeartbeatTimer) return;
|
|
567
|
+
this._presenceHeartbeatTimer = setTimeout(() => {
|
|
568
|
+
this._presenceHeartbeatTimer = null;
|
|
569
|
+
if (!this._running) return;
|
|
570
|
+
this._heartbeat().catch(() => {});
|
|
571
|
+
}, PRESENCE_HEARTBEAT_DEBOUNCE_MS);
|
|
572
|
+
if (typeof this._presenceHeartbeatTimer.unref === 'function') this._presenceHeartbeatTimer.unref();
|
|
573
|
+
}
|
|
574
|
+
|
|
464
575
|
async _heartbeat() {
|
|
465
576
|
try {
|
|
466
|
-
await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId);
|
|
577
|
+
await this.client.heartbeat(this.workspaceId, this.agentName, this.token, this._sessionId, this.presenceFields());
|
|
467
578
|
this._heartbeatFailStreak = 0;
|
|
468
579
|
const recovered = this._heartbeatFailures.succeed();
|
|
469
580
|
if (recovered) {
|
|
@@ -518,10 +629,16 @@ class BaseAdapter {
|
|
|
518
629
|
|
|
519
630
|
async _pollControl() {
|
|
520
631
|
try {
|
|
521
|
-
const events = await this.
|
|
522
|
-
this.
|
|
523
|
-
|
|
632
|
+
const events = await this._withinControlDeadline(
|
|
633
|
+
this.client.pollControl(
|
|
634
|
+
this.workspaceId, this.agentName, this.token,
|
|
635
|
+
{ after: this._lastControlId }
|
|
636
|
+
),
|
|
637
|
+
this._CONTROL_POLL_DEADLINE_MS, 'control poll',
|
|
524
638
|
);
|
|
639
|
+
// A poll abandoned at its deadline delivered nothing; the cursor has
|
|
640
|
+
// not moved, so the next one asks for the same events again.
|
|
641
|
+
if (events === CONTROL_DEADLINE_HIT) return;
|
|
525
642
|
for (const ev of events) {
|
|
526
643
|
if (ev.id) this._lastControlId = ev.id;
|
|
527
644
|
const payload = ev.payload || {};
|
|
@@ -537,12 +654,48 @@ class BaseAdapter {
|
|
|
537
654
|
// Handled here for every adapter, never by an override: a stop has
|
|
538
655
|
// to stay inside the channel it names, and each adapter used to
|
|
539
656
|
// decide that for itself — several stopped every channel.
|
|
540
|
-
await this.
|
|
657
|
+
await this._withinControlDeadline(
|
|
658
|
+
this._handleUserStop(payload.channel || null),
|
|
659
|
+
this._CONTROL_ACTION_DEADLINE_MS, `stop for ${payload.channel || 'every channel'}`,
|
|
660
|
+
);
|
|
541
661
|
} else {
|
|
542
|
-
await this.
|
|
662
|
+
await this._withinControlDeadline(
|
|
663
|
+
this._onControlAction(action, payload),
|
|
664
|
+
this._CONTROL_ACTION_DEADLINE_MS, `control action '${action}'`,
|
|
665
|
+
);
|
|
543
666
|
}
|
|
544
667
|
}
|
|
545
|
-
|
|
668
|
+
const recovered = this._controlFailures.succeed();
|
|
669
|
+
if (recovered) {
|
|
670
|
+
this._log(`Control poll recovered after ${recovered.count} unsuccessful attempt(s) over ${formatDuration(recovered.ms)}`);
|
|
671
|
+
}
|
|
672
|
+
} catch (e) {
|
|
673
|
+
// Keep polling, but leave a trace: a control channel that keeps failing
|
|
674
|
+
// means Stop and mode changes are not reaching this agent.
|
|
675
|
+
if (this._controlFailures.fail()) {
|
|
676
|
+
const count = this._controlFailures.count;
|
|
677
|
+
const streak = count > 1 ? ` (consecutive failures: ${count})` : '';
|
|
678
|
+
this._log(`Control poll failed: ${e && e.message}${streak}`, isTransientNetworkError(e) ? 'warn' : 'error');
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Await `work` for at most `ms`. Resolves to CONTROL_DEADLINE_HIT when the
|
|
685
|
+
* deadline wins; `work` is left to finish, or not, on its own.
|
|
686
|
+
*/
|
|
687
|
+
_withinControlDeadline(work, ms, what) {
|
|
688
|
+
let timer;
|
|
689
|
+
const deadline = new Promise((resolve) => {
|
|
690
|
+
timer = setTimeout(() => {
|
|
691
|
+
this._log(`Control poller: ${what} still pending after ${Math.round(ms / 1000)}s — polling on without it`, 'warn');
|
|
692
|
+
resolve(CONTROL_DEADLINE_HIT);
|
|
693
|
+
}, ms);
|
|
694
|
+
});
|
|
695
|
+
return Promise.race([
|
|
696
|
+
Promise.resolve(work).finally(() => clearTimeout(timer)),
|
|
697
|
+
deadline,
|
|
698
|
+
]);
|
|
546
699
|
}
|
|
547
700
|
|
|
548
701
|
/**
|
|
@@ -1216,6 +1369,7 @@ class BaseAdapter {
|
|
|
1216
1369
|
msg._queueId = queueId;
|
|
1217
1370
|
msg._acceptedGeneration = this._stopGenerationFor(channel);
|
|
1218
1371
|
this._channelQueues[channel].push(msg);
|
|
1372
|
+
this._schedulePresenceHeartbeat();
|
|
1219
1373
|
try {
|
|
1220
1374
|
await this.sendStatus(channel, 'message queued — will process after current task', {
|
|
1221
1375
|
queued_message: (msg.content || '').slice(0, 200),
|
|
@@ -1246,6 +1400,7 @@ class BaseAdapter {
|
|
|
1246
1400
|
|
|
1247
1401
|
async _channelWorker(channel, msg) {
|
|
1248
1402
|
this._channelBusy.add(channel);
|
|
1403
|
+
this._schedulePresenceHeartbeat();
|
|
1249
1404
|
// Messages an adapter queues for itself (Claude's todo nudge) carry no
|
|
1250
1405
|
// generation; they belong to the turn that queued them.
|
|
1251
1406
|
const startGeneration = msg._acceptedGeneration !== undefined
|
|
@@ -1265,6 +1420,17 @@ class BaseAdapter {
|
|
|
1265
1420
|
}
|
|
1266
1421
|
// Pinned entries may have changed while this message waited.
|
|
1267
1422
|
await this._prefetchPinnedContext(channel);
|
|
1423
|
+
this._channelRequestContent[channel] = current.originalContent ?? current.content;
|
|
1424
|
+
if (channel && !channel.startsWith('dm:') && current.messageId
|
|
1425
|
+
&& typeof this.client?.getRecentMessages === 'function') {
|
|
1426
|
+
await this._applyThreadHistory(channel, current);
|
|
1427
|
+
}
|
|
1428
|
+
// A teammate's request runs under the owner-reviewed shared
|
|
1429
|
+
// instructions (v1.1 M4); the block is prepended to the message.
|
|
1430
|
+
// Gated synchronously so messages it cannot apply to (agents,
|
|
1431
|
+
// anonymous humans, system) add no extra tick before the stop check.
|
|
1432
|
+
this._applyThreadCollaboration(channel, current);
|
|
1433
|
+
if (this._sharedContextCandidate(current)) await this._applySharedContext(channel, current);
|
|
1268
1434
|
// Checked after the round trips above, which a stop can land inside:
|
|
1269
1435
|
// a message from before the stop never reaches the CLI.
|
|
1270
1436
|
if (generation !== this._stopGenerationFor(channel)) {
|
|
@@ -1275,6 +1441,7 @@ class BaseAdapter {
|
|
|
1275
1441
|
this._mutedChannels.delete(channel);
|
|
1276
1442
|
this._stopNoticeSent.delete(channel);
|
|
1277
1443
|
this._stoppingChannels.delete(channel);
|
|
1444
|
+
this._channelTriggerId[channel] = current.messageId || null;
|
|
1278
1445
|
await this._handleMessage(current);
|
|
1279
1446
|
}
|
|
1280
1447
|
} catch (e) {
|
|
@@ -1282,6 +1449,8 @@ class BaseAdapter {
|
|
|
1282
1449
|
try { await this.sendError(channel, `Agent error: ${e.message}`); } catch {}
|
|
1283
1450
|
}
|
|
1284
1451
|
delete this._channelRunGeneration[channel];
|
|
1452
|
+
delete this._channelRequestContent[channel];
|
|
1453
|
+
delete this._channelTriggerId[channel];
|
|
1285
1454
|
first = false;
|
|
1286
1455
|
|
|
1287
1456
|
const queue = this._channelQueues[channel];
|
|
@@ -1289,6 +1458,105 @@ class BaseAdapter {
|
|
|
1289
1458
|
}
|
|
1290
1459
|
if (dropped) this._log(`Stop in ${channel} — dropped ${dropped} message(s) accepted before it`);
|
|
1291
1460
|
this._channelBusy.delete(channel);
|
|
1461
|
+
delete this._requesterEmail[channel];
|
|
1462
|
+
this._schedulePresenceHeartbeat();
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// ------------------------------------------------------------------
|
|
1466
|
+
// Shared context for teammates' requests (v1.1 M4)
|
|
1467
|
+
// ------------------------------------------------------------------
|
|
1468
|
+
|
|
1469
|
+
/** The email of the person whose request this channel is running, if known. */
|
|
1470
|
+
requesterEmailFor(channel) {
|
|
1471
|
+
return this._requesterEmail[channel] || null;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Fetch (cached 60s per channel + requester) the owner-reviewed shared
|
|
1476
|
+
* context for this run. Returns the /shared-context payload or null.
|
|
1477
|
+
*/
|
|
1478
|
+
async _fetchSharedContext(channel, requesterEmail) {
|
|
1479
|
+
const cached = this._sharedContext[channel];
|
|
1480
|
+
const now = Date.now();
|
|
1481
|
+
if (cached && cached.requesterEmail === requesterEmail && (now - cached.fetchedAt) < SHARED_CONTEXT_TTL_MS) {
|
|
1482
|
+
return cached.ctx;
|
|
1483
|
+
}
|
|
1484
|
+
if (!this.client || typeof this.client.getSharedContext !== 'function') return null;
|
|
1485
|
+
try {
|
|
1486
|
+
const ctx = await this.client.getSharedContext(this.workspaceId, this.token, this.agentName, {
|
|
1487
|
+
requesterEmail, channel,
|
|
1488
|
+
});
|
|
1489
|
+
this._sharedContext[channel] = { fetchedAt: now, requesterEmail, ctx };
|
|
1490
|
+
return ctx;
|
|
1491
|
+
} catch (e) {
|
|
1492
|
+
this._log(`Shared-context fetch failed (${e && e.message ? e.message : e}) — running without it`);
|
|
1493
|
+
// Keep a stale-but-recent answer rather than flip-flopping on a blip.
|
|
1494
|
+
return cached && cached.requesterEmail === requesterEmail ? cached.ctx : null;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* When the triggering message comes from an identified person, look up the
|
|
1500
|
+
* shared context and, if it applies, prepend the instruction block to the
|
|
1501
|
+
* message the adapter will turn into its prompt. Records the requester for
|
|
1502
|
+
* the per-run MCP server. Never throws.
|
|
1503
|
+
*/
|
|
1504
|
+
_sharedContextEmail(msg) {
|
|
1505
|
+
if (!msg) return null;
|
|
1506
|
+
const continuation = msg.senderType === 'agent' && msg.metadata?.orchestration?.action === 'next';
|
|
1507
|
+
if (msg.senderType !== 'human' && !continuation) return null;
|
|
1508
|
+
// Continuations retain the authenticated original requester so the next
|
|
1509
|
+
// agent applies the same owner-reviewed shared context and tool identity.
|
|
1510
|
+
const raw = continuation ? (msg.metadata.requester_email || '')
|
|
1511
|
+
: (msg.senderEmail || (msg.metadata && msg.metadata.sender_email) || '');
|
|
1512
|
+
const email = String(raw).trim().toLowerCase();
|
|
1513
|
+
return email && email.includes('@') ? email : null;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
_sharedContextCandidate(msg) {
|
|
1517
|
+
return this._sharedContextEmail(msg) !== null;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
_applyThreadCollaboration(channel, msg) {
|
|
1521
|
+
if (msg._threadCollaborationApplied || !channel || channel.startsWith('dm:')) return;
|
|
1522
|
+
const instructions = msg.metadata?.thread_agent_instructions?.[this.agentName];
|
|
1523
|
+
if (typeof instructions !== 'string' || !instructions.trim()) return;
|
|
1524
|
+
if (msg.originalContent === undefined) msg.originalContent = msg.content;
|
|
1525
|
+
msg.content = `COLLABORATION INSTRUCTIONS FOR THIS THREAD ONLY (do not carry these into other threads; existing owner restrictions still apply):\n${instructions.slice(0, 10000)}\nEND THREAD INSTRUCTIONS\n\n${msg.content || ''}`;
|
|
1526
|
+
msg._threadCollaborationApplied = true;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
async _applySharedContext(channel, msg) {
|
|
1530
|
+
const email = this._sharedContextEmail(msg);
|
|
1531
|
+
if (!email) return false;
|
|
1532
|
+
this._requesterEmail[channel] = email;
|
|
1533
|
+
if (msg._sharedContextApplied) return true;
|
|
1534
|
+
const ctx = await this._fetchSharedContext(channel, email);
|
|
1535
|
+
const block = buildSharedContextBlock(ctx);
|
|
1536
|
+
if (!block) return false;
|
|
1537
|
+
if (msg.originalContent === undefined) msg.originalContent = msg.content;
|
|
1538
|
+
msg.content = block + '\n' + (msg.content || '');
|
|
1539
|
+
msg._sharedContextApplied = true;
|
|
1540
|
+
this._log(`Shared context applied for ${email} in ${channel} (owner: ${ctx.owner_email || '?'})`);
|
|
1541
|
+
return true;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
async _applyThreadHistory(channel, msg) {
|
|
1545
|
+
// DMs have separate peer sessions. Synthetic nudges have no event boundary.
|
|
1546
|
+
if (!channel || channel.startsWith('dm:') || !msg.messageId || msg._threadHistoryApplied) return;
|
|
1547
|
+
if (typeof this.client?.getRecentMessages !== 'function') return;
|
|
1548
|
+
try {
|
|
1549
|
+
const messages = await this.client.getRecentMessages(this.workspaceId, channel, this.token, 100, {
|
|
1550
|
+
before: msg.messageId, timeout: 5000, throwOnError: true,
|
|
1551
|
+
});
|
|
1552
|
+
const block = buildThreadHistory(messages, channel, msg);
|
|
1553
|
+
if (!block) return;
|
|
1554
|
+
if (msg.originalContent === undefined) msg.originalContent = msg.content;
|
|
1555
|
+
msg.content = block + (msg.content || '');
|
|
1556
|
+
msg._threadHistoryApplied = true;
|
|
1557
|
+
} catch (e) {
|
|
1558
|
+
this._log(`Recent thread history unavailable for ${channel}: ${e.message}`);
|
|
1559
|
+
}
|
|
1292
1560
|
}
|
|
1293
1561
|
|
|
1294
1562
|
// ------------------------------------------------------------------
|
|
@@ -1492,7 +1760,7 @@ class BaseAdapter {
|
|
|
1492
1760
|
async _autoTitleChannel(channel, content) {
|
|
1493
1761
|
if (this._titledSessions.has(channel)) return;
|
|
1494
1762
|
this._titledSessions.add(channel);
|
|
1495
|
-
const title = generateSessionTitle(content);
|
|
1763
|
+
const title = generateSessionTitle(this._channelRequestContent[channel] ?? content);
|
|
1496
1764
|
if (!title) return;
|
|
1497
1765
|
try {
|
|
1498
1766
|
const info = await this.client.getSession(this.workspaceId, channel, this.token);
|
|
@@ -1555,13 +1823,27 @@ class BaseAdapter {
|
|
|
1555
1823
|
* structured field: an earlier attempt matched the word "completed" in the
|
|
1556
1824
|
* message text and fired on every bash for-loop that ended in `done`.
|
|
1557
1825
|
*/
|
|
1558
|
-
async sendResponse(channel, content) {
|
|
1826
|
+
async sendResponse(channel, content, { statusKind = 'completed' } = {}) {
|
|
1559
1827
|
if (this._isMuted(channel)) return;
|
|
1560
1828
|
try {
|
|
1829
|
+
let attachments = [];
|
|
1830
|
+
if (!(this.disabledModules || new Set()).has('files') && channel && !channel.startsWith('dm:') && this.client.uploadFile) {
|
|
1831
|
+
const published = await publishResponseArtifacts({
|
|
1832
|
+
content,
|
|
1833
|
+
workingDir: this.workingDir,
|
|
1834
|
+
upload: (filename, bytes, contentType) => this.client.uploadFile(this.workspaceId, this.token, filename, bytes, {
|
|
1835
|
+
contentType, source: `openagents:${this.agentName}`, channelName: channel, visibility: 'inherit',
|
|
1836
|
+
}),
|
|
1837
|
+
onError: () => this._log('Could not attach a linked document; leaving its original link in the reply.'),
|
|
1838
|
+
});
|
|
1839
|
+
content = published.content;
|
|
1840
|
+
attachments = published.attachments;
|
|
1841
|
+
}
|
|
1561
1842
|
await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
|
|
1843
|
+
attachments,
|
|
1562
1844
|
senderType: 'agent',
|
|
1563
1845
|
senderName: this.agentName,
|
|
1564
|
-
metadata: { status_kind:
|
|
1846
|
+
metadata: { status_kind: statusKind, ...(this._channelTriggerId?.[channel] ? { in_reply_to: this._channelTriggerId[channel] } : {}) },
|
|
1565
1847
|
sessionId: this._sessionId,
|
|
1566
1848
|
});
|
|
1567
1849
|
} catch (e) {
|
|
@@ -1701,3 +1983,7 @@ class BaseAdapter {
|
|
|
1701
1983
|
module.exports = BaseAdapter;
|
|
1702
1984
|
module.exports.STALE_MESSAGE_MAX_AGE_MS = STALE_MESSAGE_MAX_AGE_MS;
|
|
1703
1985
|
module.exports.CURSOR_RESUME_MAX_AGE_MS = CURSOR_RESUME_MAX_AGE_MS;
|
|
1986
|
+
module.exports.PRESENCE_HEARTBEAT_DEBOUNCE_MS = PRESENCE_HEARTBEAT_DEBOUNCE_MS;
|
|
1987
|
+
module.exports.SHARED_CONTEXT_TTL_MS = SHARED_CONTEXT_TTL_MS;
|
|
1988
|
+
module.exports.buildPresenceFields = buildPresenceFields;
|
|
1989
|
+
module.exports.buildSharedContextBlock = buildSharedContextBlock;
|