@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
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The daemon's writer for daemon.log — one sink shared by the daemon and every
|
|
5
|
+
* adapter running inside it.
|
|
6
|
+
*
|
|
7
|
+
* The line format is unchanged (`<ISO time> <LEVEL> <scope>: <message>`, extra
|
|
8
|
+
* message lines following as continuation lines): Launchers already in the
|
|
9
|
+
* field parse this file, and they may run a newer core than they shipped with.
|
|
10
|
+
* What changes is how lines get there:
|
|
11
|
+
*
|
|
12
|
+
* - Batched, asynchronous writes. The old writer did an appendFileSync — an
|
|
13
|
+
* open/write/close on the event loop — plus a statSync for every line. Lines
|
|
14
|
+
* now collect in memory and go out in one append per `flushMs`, or sooner
|
|
15
|
+
* once `flushBytes` are waiting. The size check runs once per batch.
|
|
16
|
+
*
|
|
17
|
+
* - Repeat suppression. A failure that repeats every few seconds used to fill
|
|
18
|
+
* the file with identical entries and rotate out everything useful. Within
|
|
19
|
+
* each `rateWindowMs`, lines with the same level, scope and message shape
|
|
20
|
+
* (digits and ids masked) pass `rateBurst` times; the rest are counted and
|
|
21
|
+
* reported in one line when the window ends. ERROR lines always pass.
|
|
22
|
+
*
|
|
23
|
+
* - Copy-truncate rotation with gzip generations. The old rename-based
|
|
24
|
+
* rotation moved the file out from under every process that inherited a
|
|
25
|
+
* handle to it — the daemon's own stdout/stderr, and with them every
|
|
26
|
+
* adapter line — so those kept writing into the renamed backup, which the
|
|
27
|
+
* next rotation deleted while still open. Copying the file aside and then
|
|
28
|
+
* truncating it keeps every holder writing to daemon.log (they all opened
|
|
29
|
+
* it in append mode). The copy is gzipped into daemon.log.1.gz, and the
|
|
30
|
+
* older generations shift up to `keep`.
|
|
31
|
+
*
|
|
32
|
+
* Writes are best-effort, as they always were: a log that cannot be written
|
|
33
|
+
* must never take the daemon down.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const zlib = require('zlib');
|
|
38
|
+
const { pipeline } = require('stream');
|
|
39
|
+
|
|
40
|
+
const LEVEL_TOKENS = { debug: 'DEBUG', info: 'INFO', warn: 'WARN', error: 'ERROR' };
|
|
41
|
+
|
|
42
|
+
const DEFAULTS = {
|
|
43
|
+
flushMs: 250,
|
|
44
|
+
flushBytes: 64 * 1024,
|
|
45
|
+
// Backlog bound for a log that cannot keep up (a stalled disk). Past this the
|
|
46
|
+
// oldest waiting lines are dropped and the drop is reported in the log.
|
|
47
|
+
maxPendingBytes: 4 * 1024 * 1024,
|
|
48
|
+
maxBytes: 10 * 1024 * 1024,
|
|
49
|
+
keep: 5,
|
|
50
|
+
rateWindowMs: 60 * 1000,
|
|
51
|
+
rateBurst: 20,
|
|
52
|
+
// Distinct message shapes tracked per window. A shape seen after the table
|
|
53
|
+
// is full is let through rather than tracked.
|
|
54
|
+
maxRateKeys: 1024,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Every writer with lines that may still be in memory; flushed on exit. */
|
|
58
|
+
const live = new Set();
|
|
59
|
+
let exitHookInstalled = false;
|
|
60
|
+
|
|
61
|
+
function installExitHook() {
|
|
62
|
+
if (exitHookInstalled) return;
|
|
63
|
+
exitHookInstalled = true;
|
|
64
|
+
process.once('exit', () => {
|
|
65
|
+
for (const w of live) w.flushSync();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeLevel(level) {
|
|
70
|
+
return Object.prototype.hasOwnProperty.call(LEVEL_TOKENS, level) ? level : 'info';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The shape of a message: its first line with ids and numbers masked, so
|
|
75
|
+
* `Poll #257 failed` and `Poll #258 failed` count as the same line.
|
|
76
|
+
*/
|
|
77
|
+
function messageShape(message) {
|
|
78
|
+
const first = String(message).split('\n', 1)[0];
|
|
79
|
+
return first
|
|
80
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<id>')
|
|
81
|
+
.replace(/\d+/g, '#')
|
|
82
|
+
.slice(0, 200);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class LogWriter {
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} file - daemon.log
|
|
88
|
+
* @param {object} [opts] - overrides for DEFAULTS
|
|
89
|
+
*/
|
|
90
|
+
constructor(file, opts = {}) {
|
|
91
|
+
this.file = file;
|
|
92
|
+
this.opts = { ...DEFAULTS, ...opts };
|
|
93
|
+
|
|
94
|
+
this._pending = [];
|
|
95
|
+
this._pendingBytes = 0;
|
|
96
|
+
this._timer = null;
|
|
97
|
+
this._writing = false;
|
|
98
|
+
this._rotating = false;
|
|
99
|
+
this._waiters = [];
|
|
100
|
+
|
|
101
|
+
this._rate = new Map();
|
|
102
|
+
this._windowStart = Date.now();
|
|
103
|
+
this._windowTimer = null;
|
|
104
|
+
|
|
105
|
+
this.stats = { written: 0, suppressed: 0, dropped: 0, writeErrors: 0, rotations: 0 };
|
|
106
|
+
this._droppedSinceNote = 0;
|
|
107
|
+
|
|
108
|
+
live.add(this);
|
|
109
|
+
installExitHook();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Queue one entry. Returns false when it was suppressed as a repeat. */
|
|
113
|
+
write(level, scope, message) {
|
|
114
|
+
const lvl = normalizeLevel(level);
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
if (!this._admit(lvl, scope, message, now)) return false;
|
|
117
|
+
this._enqueue(lvl, scope, message, now);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Resolves once everything queued so far is on disk (or failed to be). */
|
|
122
|
+
flush() {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
this._rollWindow(Date.now());
|
|
125
|
+
if (!this._writing && this._pending.length === 0) return resolve();
|
|
126
|
+
this._waiters.push(resolve);
|
|
127
|
+
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
|
128
|
+
this._flush();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Write whatever is still queued, synchronously. For process exit, where an
|
|
134
|
+
* asynchronous write would never complete.
|
|
135
|
+
*/
|
|
136
|
+
flushSync() {
|
|
137
|
+
this._rollWindow(Date.now());
|
|
138
|
+
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
|
139
|
+
const data = this._takePending();
|
|
140
|
+
if (!data) return;
|
|
141
|
+
try {
|
|
142
|
+
fs.appendFileSync(this.file, data, 'utf-8');
|
|
143
|
+
this.stats.written += data.length;
|
|
144
|
+
} catch {
|
|
145
|
+
this.stats.writeErrors++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Flush synchronously and stop tracking this writer. */
|
|
150
|
+
close() {
|
|
151
|
+
this.flushSync();
|
|
152
|
+
if (this._windowTimer) { clearTimeout(this._windowTimer); this._windowTimer = null; }
|
|
153
|
+
live.delete(this);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------------------------
|
|
157
|
+
// Repeat suppression
|
|
158
|
+
// ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
_admit(level, scope, message, now) {
|
|
161
|
+
if (level === 'error') return true;
|
|
162
|
+
if (now - this._windowStart >= this.opts.rateWindowMs) this._rollWindow(now);
|
|
163
|
+
|
|
164
|
+
const key = `${level}\u0000${scope}\u0000${messageShape(message)}`;
|
|
165
|
+
let slot = this._rate.get(key);
|
|
166
|
+
if (!slot) {
|
|
167
|
+
if (this._rate.size >= this.opts.maxRateKeys) return true;
|
|
168
|
+
slot = { level, scope, sample: String(message).split('\n', 1)[0], seen: 0, suppressed: 0 };
|
|
169
|
+
this._rate.set(key, slot);
|
|
170
|
+
}
|
|
171
|
+
slot.seen++;
|
|
172
|
+
if (slot.seen <= this.opts.rateBurst) return true;
|
|
173
|
+
|
|
174
|
+
slot.suppressed++;
|
|
175
|
+
this.stats.suppressed++;
|
|
176
|
+
// Report at the end of this window even if nothing else is ever logged.
|
|
177
|
+
if (!this._windowTimer) {
|
|
178
|
+
const wait = Math.max(0, this._windowStart + this.opts.rateWindowMs - now);
|
|
179
|
+
this._windowTimer = setTimeout(() => {
|
|
180
|
+
this._windowTimer = null;
|
|
181
|
+
this._rollWindow(Date.now());
|
|
182
|
+
}, wait);
|
|
183
|
+
if (this._windowTimer.unref) this._windowTimer.unref();
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Close the current window: one summary line per suppressed shape. */
|
|
189
|
+
_rollWindow(now) {
|
|
190
|
+
const secs = Math.max(1, Math.round((now - this._windowStart) / 1000));
|
|
191
|
+
for (const slot of this._rate.values()) {
|
|
192
|
+
if (slot.suppressed > 0) {
|
|
193
|
+
this._enqueue(
|
|
194
|
+
slot.level,
|
|
195
|
+
slot.scope,
|
|
196
|
+
`Suppressed ${slot.suppressed} more like this in the last ${secs}s: ${slot.sample}`,
|
|
197
|
+
now,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
this._rate.clear();
|
|
202
|
+
this._windowStart = now;
|
|
203
|
+
if (this._windowTimer) { clearTimeout(this._windowTimer); this._windowTimer = null; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ------------------------------------------------------------------
|
|
207
|
+
// Batching
|
|
208
|
+
// ------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
_format(level, scope, message, now) {
|
|
211
|
+
return `${new Date(now).toISOString()} ${LEVEL_TOKENS[level]} ${scope}: ${message}\n`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_enqueue(level, scope, message, now) {
|
|
215
|
+
const line = this._format(level, scope, message, now);
|
|
216
|
+
this._pending.push(line);
|
|
217
|
+
this._pendingBytes += line.length;
|
|
218
|
+
|
|
219
|
+
while (this._pendingBytes > this.opts.maxPendingBytes && this._pending.length > 1) {
|
|
220
|
+
this._pendingBytes -= this._pending.shift().length;
|
|
221
|
+
this.stats.dropped++;
|
|
222
|
+
this._droppedSinceNote++;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this._schedule(this._pendingBytes >= this.opts.flushBytes ? 0 : this.opts.flushMs);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_schedule(delay) {
|
|
229
|
+
// A write in progress picks up the backlog when it completes.
|
|
230
|
+
if (this._writing) return;
|
|
231
|
+
if (this._timer) {
|
|
232
|
+
if (delay > 0) return;
|
|
233
|
+
clearTimeout(this._timer);
|
|
234
|
+
}
|
|
235
|
+
this._timer = setTimeout(() => {
|
|
236
|
+
this._timer = null;
|
|
237
|
+
this._flush();
|
|
238
|
+
}, delay);
|
|
239
|
+
if (this._timer.unref) this._timer.unref();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_takePending() {
|
|
243
|
+
if (this._droppedSinceNote > 0) {
|
|
244
|
+
this._pending.unshift(this._format(
|
|
245
|
+
'warn', 'daemon',
|
|
246
|
+
`Log writer fell behind — dropped ${this._droppedSinceNote} line(s)`,
|
|
247
|
+
Date.now(),
|
|
248
|
+
));
|
|
249
|
+
this._droppedSinceNote = 0;
|
|
250
|
+
}
|
|
251
|
+
if (this._pending.length === 0) return '';
|
|
252
|
+
const data = this._pending.join('');
|
|
253
|
+
this._pending = [];
|
|
254
|
+
this._pendingBytes = 0;
|
|
255
|
+
return data;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_flush() {
|
|
259
|
+
if (this._writing) return;
|
|
260
|
+
const data = this._takePending();
|
|
261
|
+
if (!data) return this._settle();
|
|
262
|
+
|
|
263
|
+
this._writing = true;
|
|
264
|
+
fs.appendFile(this.file, data, 'utf-8', (err) => {
|
|
265
|
+
if (err) this.stats.writeErrors++;
|
|
266
|
+
else this.stats.written += data.length;
|
|
267
|
+
this._maybeRotate(() => {
|
|
268
|
+
this._writing = false;
|
|
269
|
+
if (this._pending.length > 0) this._flush();
|
|
270
|
+
else this._settle();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_settle() {
|
|
276
|
+
if (this._writing || this._pending.length > 0) return;
|
|
277
|
+
const waiters = this._waiters;
|
|
278
|
+
this._waiters = [];
|
|
279
|
+
for (const resolve of waiters) resolve();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ------------------------------------------------------------------
|
|
283
|
+
// Rotation
|
|
284
|
+
// ------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
_gen(n) {
|
|
287
|
+
return `${this.file}.${n}.gz`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_maybeRotate(done) {
|
|
291
|
+
if (this._rotating) return done();
|
|
292
|
+
fs.stat(this.file, (err, st) => {
|
|
293
|
+
if (err || st.size < this.opts.maxBytes) return done();
|
|
294
|
+
this._rotate(done);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Copy the full log aside, truncate it in place, then compress the copy in
|
|
300
|
+
* the background. `done` runs as soon as the truncate lands, so writing
|
|
301
|
+
* continues into the emptied file while the copy is compressed.
|
|
302
|
+
*
|
|
303
|
+
* A line another process appends between the copy and the truncate is lost.
|
|
304
|
+
* That window is the duration of one file copy, once per `maxBytes` of log.
|
|
305
|
+
*/
|
|
306
|
+
_rotate(done) {
|
|
307
|
+
this._rotating = true;
|
|
308
|
+
const copy = `${this.file}.rotating`;
|
|
309
|
+
try {
|
|
310
|
+
fs.copyFileSync(this.file, copy);
|
|
311
|
+
fs.truncateSync(this.file, 0);
|
|
312
|
+
} catch {
|
|
313
|
+
try { fs.unlinkSync(copy); } catch { /* not created */ }
|
|
314
|
+
this._rotating = false;
|
|
315
|
+
return done();
|
|
316
|
+
}
|
|
317
|
+
this.stats.rotations++;
|
|
318
|
+
done();
|
|
319
|
+
|
|
320
|
+
this._shiftGenerations();
|
|
321
|
+
const target = this._gen(1);
|
|
322
|
+
const partial = `${target}.tmp`;
|
|
323
|
+
pipeline(
|
|
324
|
+
fs.createReadStream(copy),
|
|
325
|
+
zlib.createGzip(),
|
|
326
|
+
fs.createWriteStream(partial),
|
|
327
|
+
(err) => {
|
|
328
|
+
try { fs.unlinkSync(copy); } catch { /* already gone */ }
|
|
329
|
+
if (err) {
|
|
330
|
+
try { fs.unlinkSync(partial); } catch { /* not created */ }
|
|
331
|
+
} else {
|
|
332
|
+
try { fs.renameSync(partial, target); } catch { /* best effort */ }
|
|
333
|
+
}
|
|
334
|
+
this._rotating = false;
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
_shiftGenerations() {
|
|
340
|
+
const { keep } = this.opts;
|
|
341
|
+
try { fs.unlinkSync(this._gen(keep)); } catch { /* none yet */ }
|
|
342
|
+
for (let n = keep - 1; n >= 1; n--) {
|
|
343
|
+
try { fs.renameSync(this._gen(n), this._gen(n + 1)); } catch { /* none yet */ }
|
|
344
|
+
}
|
|
345
|
+
// The uncompressed backup the previous writer kept. It was always deleted
|
|
346
|
+
// at the next rotation; this is that rotation.
|
|
347
|
+
try { fs.unlinkSync(`${this.file}.1`); } catch { /* none */ }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = { LogWriter, messageShape, DEFAULTS };
|
package/src/mcp-server.js
CHANGED
|
@@ -22,6 +22,8 @@ const _activeTunnels = {};
|
|
|
22
22
|
|
|
23
23
|
// ── Tool definitions ────────────────────────────────────────────────────────
|
|
24
24
|
|
|
25
|
+
const v11 = require('./tools-v11');
|
|
26
|
+
|
|
25
27
|
function buildToolDefs(disabledModules) {
|
|
26
28
|
const tools = [
|
|
27
29
|
// -- Workspace core (always enabled) --
|
|
@@ -73,6 +75,21 @@ function buildToolDefs(disabledModules) {
|
|
|
73
75
|
required: ['file_id'],
|
|
74
76
|
},
|
|
75
77
|
},
|
|
78
|
+
{
|
|
79
|
+
name: 'workspace_publish_project',
|
|
80
|
+
description: 'Publish a static web project as one previewable artifact. Include its HTML, images, CSS, JavaScript and data using relative paths. No servers or dependencies are installed. Exclude secrets and hidden files. Reuse project_id and expected_revision to update while keeping its link and pins.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
title: { type: 'string' }, entrypoint: { type: 'string', description: 'Relative HTML entry page, usually index.html' },
|
|
85
|
+
project_id: { type: 'string' }, expected_revision: { type: 'string', description: 'Revision returned by the last publish or preview' },
|
|
86
|
+
files: { type: 'array', maxItems: 500, items: { type: 'object', properties: {
|
|
87
|
+
path: { type: 'string' }, content: { type: 'string', description: 'UTF-8 text, or use content_base64 for binary files' },
|
|
88
|
+
content_base64: { type: 'string' },
|
|
89
|
+
}, required: ['path'] } },
|
|
90
|
+
}, required: ['title', 'files'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
76
93
|
{
|
|
77
94
|
name: 'workspace_write_file',
|
|
78
95
|
description: 'Write/upload a file to shared workspace storage.',
|
|
@@ -442,6 +459,40 @@ function buildToolDefs(disabledModules) {
|
|
|
442
459
|
);
|
|
443
460
|
}
|
|
444
461
|
|
|
462
|
+
// -- Approvals (always on: a governance primitive, not a skill) --
|
|
463
|
+
tools.push(
|
|
464
|
+
{
|
|
465
|
+
name: 'workspace_request_approval',
|
|
466
|
+
description: 'Ask a human for permission BEFORE doing something consequential: deploying to production, spending money or calling paid APIs, sending email/messages to customers or external parties, deleting data, or any irreversible action you are unsure about. Workspace policy decides: the request may be auto-approved, auto-blocked, or paused until a person with the right role approves it in the thread. This call blocks (up to wait_seconds) until a decision arrives and returns it. If it returns "pending", stop and wait — the decision will arrive as a message in this thread.',
|
|
467
|
+
inputSchema: {
|
|
468
|
+
type: 'object',
|
|
469
|
+
properties: {
|
|
470
|
+
action: { type: 'string', description: 'Short label of what you want to do, e.g. "Deploy 2.4 hotfix to prod-eu" or "Email 38 affected customers"' },
|
|
471
|
+
kind: {
|
|
472
|
+
type: 'string',
|
|
473
|
+
enum: ['deploy', 'spend', 'external_send', 'repo_read', 'repo_write', 'data_delete', 'shell', 'other'],
|
|
474
|
+
description: 'Category the policy is keyed on. deploy=production deploys; spend=money/paid APIs; external_send=email/messages leaving the workspace; repo_read/repo_write=git; data_delete=destructive data ops; shell=commands with side effects; other=anything else',
|
|
475
|
+
},
|
|
476
|
+
details: { type: 'string', description: 'The exact command, diff summary, recipient list, URL or amount — what a reviewer needs to decide' },
|
|
477
|
+
risk: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Your own risk estimate' },
|
|
478
|
+
wait_seconds: { type: 'integer', description: 'How long to wait for a decision before returning "pending" (default 300, max 900)' },
|
|
479
|
+
},
|
|
480
|
+
required: ['action', 'kind'],
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
name: 'workspace_check_approval',
|
|
485
|
+
description: 'Check the status of an approval request you made earlier (by id). Use after workspace_request_approval returned "pending".',
|
|
486
|
+
inputSchema: {
|
|
487
|
+
type: 'object',
|
|
488
|
+
properties: {
|
|
489
|
+
approval_id: { type: 'string', description: 'The approval id returned by workspace_request_approval' },
|
|
490
|
+
},
|
|
491
|
+
required: ['approval_id'],
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
);
|
|
495
|
+
|
|
445
496
|
// -- Knowledge Base --
|
|
446
497
|
if (!disabledModules.has('knowledge')) {
|
|
447
498
|
tools.push(
|
|
@@ -489,9 +540,37 @@ function buildToolDefs(disabledModules) {
|
|
|
489
540
|
);
|
|
490
541
|
}
|
|
491
542
|
|
|
543
|
+
// -- v1.1 milestone tools (always on) --
|
|
544
|
+
tools.push(...v11.toolDefs());
|
|
545
|
+
|
|
492
546
|
return tools;
|
|
493
547
|
}
|
|
494
548
|
|
|
549
|
+
// ── Approvals ───────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* What the agent reads back from an approval. The verdict comes first so a
|
|
553
|
+
* model skimming the tool result cannot miss it.
|
|
554
|
+
*/
|
|
555
|
+
function formatApproval(a) {
|
|
556
|
+
const who = a.resolved_by === 'policy'
|
|
557
|
+
? 'workspace policy'
|
|
558
|
+
: a.resolved_by ? `${a.resolved_by}${a.resolved_by_role ? ` (${a.resolved_by_role})` : ''}` : null;
|
|
559
|
+
const note = a.note ? `\nNote from approver: ${a.note}` : '';
|
|
560
|
+
switch (a.status) {
|
|
561
|
+
case 'approved':
|
|
562
|
+
return `APPROVED — "${a.action}" was approved by ${who}. You may proceed.${note}\n(approval id: ${a.id})`;
|
|
563
|
+
case 'rejected':
|
|
564
|
+
return `REJECTED — "${a.action}" was rejected by ${who}. Do NOT do it. Explain in the thread what you would have done and ask how to proceed.${note}\n(approval id: ${a.id})`;
|
|
565
|
+
case 'expired':
|
|
566
|
+
return `EXPIRED — nobody decided on "${a.action}" in time. Do not proceed; ask in the thread.\n(approval id: ${a.id})`;
|
|
567
|
+
default: {
|
|
568
|
+
const role = a.required_role === 'admin' ? 'an Admin' : a.required_role === 'owner' ? 'the Owner' : 'a workspace member';
|
|
569
|
+
return `PENDING — "${a.action}" is waiting for ${role} to approve. Do NOT proceed with this action. Stop here and end your turn; the decision will arrive as a new message in this thread (@mentioning you), or call workspace_check_approval with id ${a.id} later.`;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
495
574
|
// ── MIME type detection ─────────────────────────────────────────────────────
|
|
496
575
|
|
|
497
576
|
const MIME_MAP = {
|
|
@@ -521,12 +600,13 @@ function isTextMime(mime) {
|
|
|
521
600
|
// ── MCP Server ──────────────────────────────────────────────────────────────
|
|
522
601
|
|
|
523
602
|
class McpServer {
|
|
524
|
-
constructor({ wsClient, workspaceId, channelName, agentName, token, disabledModules }) {
|
|
603
|
+
constructor({ wsClient, workspaceId, channelName, agentName, token, disabledModules, requesterEmail = process.env.OPENAGENTS_REQUESTER_EMAIL || null }) {
|
|
525
604
|
this.ws = wsClient;
|
|
526
605
|
this.workspaceId = workspaceId;
|
|
527
606
|
this.channelName = channelName;
|
|
528
607
|
this.agentName = agentName;
|
|
529
608
|
this.token = token;
|
|
609
|
+
this.requesterEmail = requesterEmail;
|
|
530
610
|
this.disabledModules = disabledModules || new Set();
|
|
531
611
|
this.tools = buildToolDefs(this.disabledModules);
|
|
532
612
|
}
|
|
@@ -662,6 +742,20 @@ class McpServer {
|
|
|
662
742
|
return text(str);
|
|
663
743
|
}
|
|
664
744
|
|
|
745
|
+
case 'workspace_publish_project': {
|
|
746
|
+
const channel = this.channelName && !this.channelName.startsWith('dm:') ? this.channelName : undefined;
|
|
747
|
+
const files = args.files.map(file => {
|
|
748
|
+
if (typeof file.content_base64 !== 'string' && typeof file.content !== 'string') throw new Error('Each file needs content or content_base64');
|
|
749
|
+
return { path:file.path, content_base64:file.content_base64 ?? Buffer.from(file.content,'utf8').toString('base64') };
|
|
750
|
+
});
|
|
751
|
+
const result = await this.ws.publishWebProject(this.workspaceId, this.token, {
|
|
752
|
+
title:args.title, entrypoint:args.entrypoint || 'index.html', files,
|
|
753
|
+
channel_name:channel, visibility:channel ? 'inherit' : 'private',
|
|
754
|
+
source:`openagents:${this.agentName}`, expected_revision:args.expected_revision,
|
|
755
|
+
}, args.project_id);
|
|
756
|
+
return text(`Project published (id: ${result.id}, revision: ${result.revision}). Link it in your reply: [${args.title}](/v1/files/${encodeURIComponent(result.id)})`);
|
|
757
|
+
}
|
|
758
|
+
|
|
665
759
|
case 'workspace_write_file': {
|
|
666
760
|
const mime = args.content_type || detectMime(args.filename);
|
|
667
761
|
let b64;
|
|
@@ -674,9 +768,10 @@ class McpServer {
|
|
|
674
768
|
contentType: mime,
|
|
675
769
|
source: `openagents:${this.agentName}`,
|
|
676
770
|
channelName: this.channelName,
|
|
771
|
+
visibility: this.channelName ? 'inherit' : undefined,
|
|
677
772
|
});
|
|
678
773
|
const fileId = result.id || result.file_id || 'unknown';
|
|
679
|
-
return text(`File written: ${args.filename} (id: ${fileId})`);
|
|
774
|
+
return text(`File written: ${args.filename} (id: ${fileId}). Link it in your reply: [${args.filename}](/v1/files/${encodeURIComponent(fileId)})`);
|
|
680
775
|
}
|
|
681
776
|
|
|
682
777
|
case 'workspace_delete_file': {
|
|
@@ -745,6 +840,7 @@ class McpServer {
|
|
|
745
840
|
const opts = {
|
|
746
841
|
url: args.url || 'about:blank',
|
|
747
842
|
source: `openagents:${this.agentName}`,
|
|
843
|
+
channel: this.channelName,
|
|
748
844
|
};
|
|
749
845
|
// Resolve context_name to context_id
|
|
750
846
|
if (args.context_name) {
|
|
@@ -767,29 +863,29 @@ class McpServer {
|
|
|
767
863
|
}
|
|
768
864
|
|
|
769
865
|
case 'workspace_browser_navigate': {
|
|
770
|
-
const result = await this.ws.browserNavigate(this.workspaceId, this.token, args.tab_id, args.url);
|
|
866
|
+
const result = await this.ws.browserNavigate(this.workspaceId, this.token, args.tab_id, args.url, { source: `openagents:${this.agentName}`, channel: this.channelName });
|
|
771
867
|
const title = result.title || '';
|
|
772
868
|
return text(`Navigated to: ${args.url}${title ? ` (title: ${title})` : ''}`);
|
|
773
869
|
}
|
|
774
870
|
|
|
775
871
|
case 'workspace_browser_click': {
|
|
776
|
-
const result = await this.ws.browserClick(this.workspaceId, this.token, args.tab_id, args.selector);
|
|
872
|
+
const result = await this.ws.browserClick(this.workspaceId, this.token, args.tab_id, args.selector, { source: `openagents:${this.agentName}`, channel: this.channelName });
|
|
777
873
|
return text(`Clicked: ${args.selector}${result.url ? ` (url now: ${result.url})` : ''}`);
|
|
778
874
|
}
|
|
779
875
|
|
|
780
876
|
case 'workspace_browser_type': {
|
|
781
|
-
await this.ws.browserType(this.workspaceId, this.token, args.tab_id, args.selector, args.text);
|
|
877
|
+
await this.ws.browserType(this.workspaceId, this.token, args.tab_id, args.selector, args.text, { source: `openagents:${this.agentName}`, channel: this.channelName });
|
|
782
878
|
return text(`Typed into ${args.selector}: ${args.text.slice(0, 50)}${args.text.length > 50 ? '...' : ''}`);
|
|
783
879
|
}
|
|
784
880
|
|
|
785
881
|
case 'workspace_browser_screenshot': {
|
|
786
|
-
const buf = await this.ws.browserScreenshot(this.workspaceId, this.token, args.tab_id);
|
|
882
|
+
const buf = await this.ws.browserScreenshot(this.workspaceId, this.token, args.tab_id, { source: `openagents:${this.agentName}`, channel: this.channelName });
|
|
787
883
|
const b64 = Buffer.isBuffer(buf) ? buf.toString('base64') : buf;
|
|
788
884
|
return image(b64, 'image/png');
|
|
789
885
|
}
|
|
790
886
|
|
|
791
887
|
case 'workspace_browser_snapshot': {
|
|
792
|
-
const snapshot = await this.ws.browserSnapshot(this.workspaceId, this.token, args.tab_id);
|
|
888
|
+
const snapshot = await this.ws.browserSnapshot(this.workspaceId, this.token, args.tab_id, { source: `openagents:${this.agentName}`, channel: this.channelName });
|
|
793
889
|
return text(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot));
|
|
794
890
|
}
|
|
795
891
|
|
|
@@ -1039,6 +1135,35 @@ class McpServer {
|
|
|
1039
1135
|
return text(lines.join('\n'));
|
|
1040
1136
|
}
|
|
1041
1137
|
|
|
1138
|
+
// ── Approvals ──
|
|
1139
|
+
|
|
1140
|
+
case 'workspace_request_approval': {
|
|
1141
|
+
const waitSec = Math.max(0, Math.min(900, Number(args.wait_seconds) || 300));
|
|
1142
|
+
let approval = await this.ws.createApproval(this.workspaceId, this.token, {
|
|
1143
|
+
channel: this.channelName,
|
|
1144
|
+
kind: args.kind || 'other',
|
|
1145
|
+
action: args.action,
|
|
1146
|
+
details: args.details,
|
|
1147
|
+
risk: args.risk,
|
|
1148
|
+
source: `openagents:${this.agentName}`,
|
|
1149
|
+
});
|
|
1150
|
+
const deadline = Date.now() + waitSec * 1000;
|
|
1151
|
+
while (approval.status === 'pending' && Date.now() < deadline) {
|
|
1152
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
1153
|
+
try {
|
|
1154
|
+
approval = await this.ws.getApproval(this.workspaceId, this.token, approval.id);
|
|
1155
|
+
} catch (e) {
|
|
1156
|
+
this._log(`approval poll failed: ${e.message}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return text(formatApproval(approval));
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
case 'workspace_check_approval': {
|
|
1163
|
+
const approval = await this.ws.getApproval(this.workspaceId, this.token, args.approval_id);
|
|
1164
|
+
return text(formatApproval(approval));
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1042
1167
|
// ── Knowledge Base ──
|
|
1043
1168
|
|
|
1044
1169
|
case 'workspace_list_knowledge': {
|
|
@@ -1087,8 +1212,11 @@ class McpServer {
|
|
|
1087
1212
|
return text(`Knowledge entry deleted: ${args.entry_id}`);
|
|
1088
1213
|
}
|
|
1089
1214
|
|
|
1090
|
-
default:
|
|
1215
|
+
default: {
|
|
1216
|
+
const handled = await v11.dispatch(this, name, args, { text, image });
|
|
1217
|
+
if (handled !== undefined) return handled;
|
|
1091
1218
|
throw new Error(`Unknown tool: ${name}`);
|
|
1219
|
+
}
|
|
1092
1220
|
}
|
|
1093
1221
|
}
|
|
1094
1222
|
|
|
@@ -1114,7 +1242,8 @@ function jsonRpcError(id, code, message) {
|
|
|
1114
1242
|
// ── Entry point (called from cli.js) ────────────────────────────────────────
|
|
1115
1243
|
|
|
1116
1244
|
function runMcpServer(opts) {
|
|
1117
|
-
|
|
1245
|
+
// Every tool call (incl. tools-v11 via server.ws) is made for opts.agentName.
|
|
1246
|
+
const wsClient = new WorkspaceClient(opts.endpoint, { agentName: opts.agentName });
|
|
1118
1247
|
const server = new McpServer({
|
|
1119
1248
|
wsClient,
|
|
1120
1249
|
workspaceId: opts.workspaceId,
|
package/src/node-config.js
CHANGED
|
@@ -98,7 +98,7 @@ function recordPairing(nodeKey, pairing) {
|
|
|
98
98
|
const stamped = { ...pairing, paired_at: new Date().toISOString() };
|
|
99
99
|
const pairings = [
|
|
100
100
|
stamped,
|
|
101
|
-
...pairingsOf(readNode()).filter((p) => p.workspace_id !== pairing.workspace_id),
|
|
101
|
+
...pairingsOf(readNode()).filter((p) => pairing.node_id && p.node_id ? p.node_id !== pairing.node_id : p.workspace_id !== pairing.workspace_id),
|
|
102
102
|
];
|
|
103
103
|
saveNode({ node_key: nodeKey, ...stamped, pairings });
|
|
104
104
|
}
|
|
@@ -116,17 +116,19 @@ function recordPairing(nodeKey, pairing) {
|
|
|
116
116
|
* @param workspaceId which pairing to drop; defaults to the top-level one.
|
|
117
117
|
* @returns the pairing that was dropped, or null if there was no such pairing.
|
|
118
118
|
*/
|
|
119
|
-
function clearPairing(workspaceId) {
|
|
119
|
+
function clearPairing(workspaceId, nodeId) {
|
|
120
120
|
const record = readNode();
|
|
121
121
|
if (!record) return null;
|
|
122
122
|
const target = workspaceId || record.workspace_id;
|
|
123
123
|
if (!target) return null;
|
|
124
124
|
|
|
125
125
|
const pairings = pairingsOf(record);
|
|
126
|
-
const
|
|
126
|
+
const candidates = pairings.filter((p) => p.workspace_id === target);
|
|
127
|
+
if (!nodeId && candidates.length > 1) throw new Error("Choose a specific device connection");
|
|
128
|
+
const dropped = candidates.find((p) => !nodeId || p.node_id === nodeId);
|
|
127
129
|
if (!dropped) return null;
|
|
128
130
|
|
|
129
|
-
const remaining = pairings.filter((p) => p
|
|
131
|
+
const remaining = pairings.filter((p) => p !== dropped);
|
|
130
132
|
saveNode({ node_key: record.node_key, ...remaining[0], pairings: remaining });
|
|
131
133
|
return {
|
|
132
134
|
node_id: dropped.node_id,
|