@dev-mainsequence/codex-plugin 0.3.0 → 0.7.9
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/.codex-plugin/plugin.json +6 -5
- package/.mcp.json +17 -0
- package/README.md +49 -11
- package/assets/mainsequence-mark.svg +453 -0
- package/build-info.json +3 -3
- package/dist/hosts/codexPresentation.js +1 -0
- package/dist/hosts/discovery.js +252 -0
- package/dist/hosts/messaging.js +63 -0
- package/dist/hosts/panel/panel.html +265 -0
- package/dist/hosts/workspace.js +107 -0
- package/dist/panel-server.js +223 -0
- package/dist/vendor/devtools/index.js +45 -1
- package/dist/vendor/devtools/package.json +1 -1
- package/dist/vendor/devtools/shared/accountScope.js +19 -0
- package/dist/vendor/devtools/shared/actions.js +44 -7
- package/dist/vendor/devtools/shared/agentAssets.js +38 -0
- package/dist/vendor/devtools/shared/agentDelivery.js +477 -0
- package/dist/vendor/devtools/shared/agentIcon.js +43 -0
- package/dist/vendor/devtools/shared/agents.js +18 -7
- package/dist/vendor/devtools/shared/arguments.js +16 -9
- package/dist/vendor/devtools/shared/deployments.js +214 -0
- package/dist/vendor/devtools/shared/environments.js +38 -0
- package/dist/vendor/devtools/shared/mcp.js +237 -46
- package/dist/vendor/devtools/shared/mcpTransport.js +79 -23
- package/dist/vendor/devtools/shared/organizationProjectWorkspace.js +8 -3
- package/dist/vendor/devtools/shared/organizationProjects.js +2 -13
- package/dist/vendor/devtools/shared/projectTools.js +437 -0
- package/dist/vendor/devtools/shared/repositories.js +2 -2
- package/dist/vendor/devtools/shared/session.js +133 -14
- package/dist/vendor/devtools/shared/sharedTransport.js +506 -0
- package/dist/vendor/devtools/shared/stateDirectory.js +44 -0
- package/dist/vendor/devtools/shared/workingDirectory.js +13 -0
- package/dist/vendor/devtools/shared/workspaceSettings.js +96 -0
- package/docs/agents-tools-panel.md +522 -0
- package/docs/codex-verification.md +37 -0
- package/docs/conversational-mcp.md +16 -3
- package/package.json +9 -5
- package/skills/mainsequence-mcp/SKILL.md +19 -4
- package/skills/ms/assets/mainsequence-mark.svg +453 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.AgentDelivery = void 0;
|
|
37
|
+
const node_fs_1 = require("node:fs");
|
|
38
|
+
const fs = __importStar(require("node:fs/promises"));
|
|
39
|
+
const path = __importStar(require("node:path"));
|
|
40
|
+
const os = __importStar(require("node:os"));
|
|
41
|
+
const node_crypto_1 = require("node:crypto");
|
|
42
|
+
const models_1 = require("./models.js");
|
|
43
|
+
const session_1 = require("./session.js");
|
|
44
|
+
const hash = (value) => (0, node_crypto_1.createHash)('sha256').update(JSON.stringify(value)).digest('hex');
|
|
45
|
+
const uuid = (value) => {
|
|
46
|
+
if (typeof value !== 'string' || !/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(value))
|
|
47
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'A valid saved request or agent identifier is required.');
|
|
48
|
+
return value.toLowerCase();
|
|
49
|
+
};
|
|
50
|
+
const failStore = () => new models_1.ToolingError('STORE_ERROR', 'Saved agent requests could not be read safely. Existing requests were kept; no message was resent.');
|
|
51
|
+
const live = (pid) => { try {
|
|
52
|
+
process.kill(pid, 0);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
return e.code !== 'ESRCH';
|
|
57
|
+
} };
|
|
58
|
+
const view = (r) => { const { owner: _, ...publicRequest } = r; return structuredClone(publicRequest); };
|
|
59
|
+
const finished = (r) => !['pending', 'preparing', 'sending'].includes(r.state);
|
|
60
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
61
|
+
const defaultDirectory = () => path.join(process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support', 'MainSequenceCLI')
|
|
62
|
+
: process.platform === 'win32' ? path.join(process.env.APPDATA || os.homedir(), 'MainSequenceCLI') : path.join(os.homedir(), '.config', 'mainsequence'), 'agent-delivery-v1');
|
|
63
|
+
/**
|
|
64
|
+
* Durable, account/chat-scoped delivery. A receipt can be read repeatedly; a send is never replayed.
|
|
65
|
+
* Native turn cancellation only cancels the caller's wait. Accepted work has its own bounded lifetime.
|
|
66
|
+
* Process loss during a send is uncertain, not evidence of failure or permission to send again.
|
|
67
|
+
*/
|
|
68
|
+
class AgentDelivery {
|
|
69
|
+
options;
|
|
70
|
+
directory;
|
|
71
|
+
jobs = new Map();
|
|
72
|
+
constructor(options) {
|
|
73
|
+
this.options = options;
|
|
74
|
+
this.directory = path.resolve(options.directory || defaultDirectory());
|
|
75
|
+
}
|
|
76
|
+
scope(input) {
|
|
77
|
+
if (!input || typeof input.host !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(input.host)
|
|
78
|
+
|| typeof input.conversationId !== 'string' || !input.conversationId.trim() || input.conversationId.length > 512
|
|
79
|
+
|| typeof input.account !== 'string' || !input.account.trim() || input.account.length > 300)
|
|
80
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'A verified account and host conversation are required.');
|
|
81
|
+
const scope = { ...input, backend: (0, session_1.requireBackend)(input.backend) };
|
|
82
|
+
return { scope, key: hash([scope.host, scope.conversationId, scope.backend, scope.account]) };
|
|
83
|
+
}
|
|
84
|
+
async verify(scope, signal) {
|
|
85
|
+
signal?.throwIfAborted();
|
|
86
|
+
const result = await this.options.createDispatch(signal)({ action: 'auth-status', backend: scope.backend, check: true });
|
|
87
|
+
const data = result.data;
|
|
88
|
+
if (result.status !== 'success')
|
|
89
|
+
throw new models_1.ToolingError(result.error?.code || 'AUTH_REJECTED', result.summary);
|
|
90
|
+
if (!data?.authenticated || data.username !== scope.account)
|
|
91
|
+
throw new models_1.ToolingError('AUTH_REJECTED', 'The MainSequence account changed. Refresh before accessing saved agent requests.');
|
|
92
|
+
signal?.throwIfAborted();
|
|
93
|
+
}
|
|
94
|
+
async secureDirectory() {
|
|
95
|
+
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
96
|
+
const stat = await fs.lstat(this.directory);
|
|
97
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()))
|
|
98
|
+
throw failStore();
|
|
99
|
+
await fs.chmod(this.directory, 0o700);
|
|
100
|
+
}
|
|
101
|
+
async readFile(file, lockRead = false) {
|
|
102
|
+
const handle = await fs.open(file, node_fs_1.constants.O_RDONLY | (node_fs_1.constants.O_NOFOLLOW || 0));
|
|
103
|
+
try {
|
|
104
|
+
const stat = await handle.stat();
|
|
105
|
+
if (lockRead && stat.nlink === 0)
|
|
106
|
+
throw Object.assign(new Error('The lock was released while opening it.'), { code: 'ENOENT' });
|
|
107
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 36_000_000 || (process.getuid && stat.uid !== process.getuid()))
|
|
108
|
+
throw failStore();
|
|
109
|
+
return await handle.readFile('utf8');
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async locked(key, run) {
|
|
116
|
+
await this.secureDirectory();
|
|
117
|
+
const file = path.join(this.directory, key + '.json'), lock = file + '.lock';
|
|
118
|
+
const owner = { pid: process.pid, token: (0, node_crypto_1.randomUUID)() };
|
|
119
|
+
let guard;
|
|
120
|
+
const deadline = Date.now() + 3000;
|
|
121
|
+
while (!guard) {
|
|
122
|
+
try {
|
|
123
|
+
guard = await fs.open(lock, 'wx', 0o600);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error.code !== 'EEXIST')
|
|
127
|
+
throw failStore();
|
|
128
|
+
let previous;
|
|
129
|
+
try {
|
|
130
|
+
previous = JSON.parse(await this.readFile(lock, true));
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
// A new exclusive file can briefly precede its owner record. Never reclaim it.
|
|
134
|
+
if (Date.now() >= deadline)
|
|
135
|
+
throw failStore();
|
|
136
|
+
if (e instanceof SyntaxError || e.code === 'ENOENT') {
|
|
137
|
+
await sleep(25);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
throw failStore();
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isSafeInteger(previous.pid) || previous.pid <= 0 || typeof previous.token !== 'string')
|
|
143
|
+
throw failStore();
|
|
144
|
+
if (!live(previous.pid)) {
|
|
145
|
+
// One durable reclamation claim per dead owner prevents deleting a replacement lock.
|
|
146
|
+
const claim = lock + '.reclaim-' + uuid(previous.token);
|
|
147
|
+
try {
|
|
148
|
+
const handle = await fs.open(claim, 'wx', 0o600);
|
|
149
|
+
await handle.close();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
throw new models_1.ToolingError('PROJECT_BUSY', 'Agent storage recovery is already running. Try reading status again.');
|
|
153
|
+
}
|
|
154
|
+
if (await this.readFile(lock) !== JSON.stringify(previous))
|
|
155
|
+
throw failStore();
|
|
156
|
+
await fs.unlink(lock);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (Date.now() >= deadline)
|
|
160
|
+
throw new models_1.ToolingError('PROJECT_BUSY', 'Another agent request is updating local storage. Try reading status again.');
|
|
161
|
+
await sleep(25);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
await guard.writeFile(JSON.stringify(owner));
|
|
166
|
+
await guard.sync();
|
|
167
|
+
let state;
|
|
168
|
+
try {
|
|
169
|
+
state = JSON.parse(await this.readFile(file));
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
if (e.code !== 'ENOENT')
|
|
173
|
+
throw failStore();
|
|
174
|
+
state = { version: 1, scope: key, requests: {} };
|
|
175
|
+
}
|
|
176
|
+
if (state?.version !== 1 || state.scope !== key || !state.requests || Array.isArray(state.requests) || typeof state.requests !== 'object' || Object.keys(state.requests).length > 256)
|
|
177
|
+
throw failStore();
|
|
178
|
+
for (const [id, r] of Object.entries(state.requests)) {
|
|
179
|
+
if (!r || uuid(id) !== id || r.requestId !== id || r.messageId !== id || typeof r.message !== 'string' || r.message.length > 64000
|
|
180
|
+
|| typeof r.agentName !== 'string' || !['pending', 'preparing', 'sending', 'delivered', 'failed', 'uncertain', 'cancelled', 'expired'].includes(r.state)
|
|
181
|
+
|| !/^host_[a-f0-9]{64}$/.test(r.handleId) || !Number.isFinite(Date.parse(r.createdAt))
|
|
182
|
+
|| !r.submission || !Number.isSafeInteger(r.submission.attempts) || r.submission.attempts < 0
|
|
183
|
+
|| !['not-submitted', 'attempted', 'acknowledged', 'unknown'].includes(r.submission.outcome)
|
|
184
|
+
|| !Number.isSafeInteger(r.sendAttempts) || r.sendAttempts < 0 || r.sendAttempts > 1 || !Number.isFinite(Date.parse(r.expiresAt)))
|
|
185
|
+
throw failStore();
|
|
186
|
+
// 'failed' is reserved for a completed devtools result proving no remote mutation.
|
|
187
|
+
// Early development records counted entry into agent-send before its preflight.
|
|
188
|
+
if (r.state === 'failed')
|
|
189
|
+
r.sendAttempts = 0;
|
|
190
|
+
uuid(r.agentUid);
|
|
191
|
+
uuid(r.environmentUid);
|
|
192
|
+
if (r.sessionUid)
|
|
193
|
+
uuid(r.sessionUid);
|
|
194
|
+
if (r.conversationKey)
|
|
195
|
+
uuid(r.conversationKey);
|
|
196
|
+
if (r.continuationTaskUid)
|
|
197
|
+
uuid(r.continuationTaskUid);
|
|
198
|
+
if (r.taskRequestId)
|
|
199
|
+
uuid(r.taskRequestId);
|
|
200
|
+
if (r.state === 'delivered' && (!r.receipt || typeof r.receipt.message !== 'string' || !r.receipt.message || r.receipt.sessionUid !== r.sessionUid))
|
|
201
|
+
throw failStore();
|
|
202
|
+
if (['preparing', 'sending'].includes(r.state)) {
|
|
203
|
+
if (!r.owner || !Number.isSafeInteger(r.owner.pid) || r.owner.pid <= 0 || typeof r.owner.token !== 'string')
|
|
204
|
+
throw failStore();
|
|
205
|
+
if (!live(r.owner.pid)) {
|
|
206
|
+
r.state = r.state === 'sending' ? 'uncertain' : 'pending';
|
|
207
|
+
r.reason = r.state === 'uncertain' ? 'The process ended during dispatch. Delivery is unknown; resend is blocked.' : 'Preparation was interrupted before sending. Recover this same request.';
|
|
208
|
+
delete r.owner;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if ((r.state === 'pending' || (r.state === 'failed' && r.sendAttempts === 0)) && Date.parse(r.expiresAt) <= Date.now()) {
|
|
212
|
+
r.state = 'expired';
|
|
213
|
+
r.reason = 'This request expired without being sent.';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const save = async () => {
|
|
217
|
+
const serialized = JSON.stringify(state);
|
|
218
|
+
if (Buffer.byteLength(serialized) > 36_000_000)
|
|
219
|
+
throw failStore();
|
|
220
|
+
const temporary = file + '.' + (0, node_crypto_1.randomUUID)() + '.tmp', handle = await fs.open(temporary, 'wx', 0o600);
|
|
221
|
+
try {
|
|
222
|
+
await handle.writeFile(serialized);
|
|
223
|
+
await handle.sync();
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
await handle.close();
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
await fs.rename(temporary, file);
|
|
230
|
+
const directory = await fs.open(this.directory, 'r');
|
|
231
|
+
try {
|
|
232
|
+
await directory.sync();
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await directory.close();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
await fs.unlink(temporary).catch(() => { });
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const result = await run(state, save);
|
|
243
|
+
await save();
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
await guard.close();
|
|
248
|
+
await fs.unlink(lock);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** Prepare a stable conversation without sending an introductory message. */
|
|
252
|
+
async prepare(input, selection, signal) {
|
|
253
|
+
const { scope, key } = this.scope(input), environmentUid = uuid(selection.environmentUid), agentUid = uuid(selection.agentUid);
|
|
254
|
+
const conversationKey = selection.conversationKey === undefined ? undefined : uuid(selection.conversationKey);
|
|
255
|
+
await this.verify(scope, signal);
|
|
256
|
+
const dispatch = this.options.createDispatch(signal), target = { backend: scope.backend, expectedAccount: scope.account, environmentUid, agentUid };
|
|
257
|
+
const handleId = 'host_' + hash(conversationKey ? [key, environmentUid, agentUid, conversationKey] : [key, environmentUid, agentUid]);
|
|
258
|
+
const session = await dispatch({ action: 'agent-session', ...target, handleId });
|
|
259
|
+
if (session.status !== 'success')
|
|
260
|
+
throw new models_1.ToolingError(session.error?.code || 'API_ERROR', session.summary);
|
|
261
|
+
const sessionUid = uuid(session.data?.sessionUid);
|
|
262
|
+
const ready = await dispatch({ action: 'agent-readiness', ...target, sessionUid, timeoutMs: 90000 });
|
|
263
|
+
if (ready.status !== 'success')
|
|
264
|
+
throw new models_1.ToolingError(ready.error?.code || 'API_ERROR', ready.summary);
|
|
265
|
+
return { sessionUid, handleId, ready: true };
|
|
266
|
+
}
|
|
267
|
+
async stage(input, draft, signal) {
|
|
268
|
+
const { scope, key } = this.scope(input), requestId = uuid(draft.requestId), agentUid = uuid(draft.agentUid), environmentUid = uuid(draft.environmentUid);
|
|
269
|
+
if (typeof draft.message !== 'string' || !draft.message.trim() || draft.message.length > 64000)
|
|
270
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Enter a message of 1–64000 characters.');
|
|
271
|
+
const conversationKey = draft.conversationKey === undefined ? undefined : uuid(draft.conversationKey);
|
|
272
|
+
const responseKind = draft.responseKind ?? 'message';
|
|
273
|
+
if (!['message', 'task'].includes(responseKind))
|
|
274
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Choose message or task response mode.');
|
|
275
|
+
await this.verify(scope, signal);
|
|
276
|
+
const detail = await this.options.createDispatch(signal)({ action: 'agent-get', backend: scope.backend, expectedAccount: scope.account, environmentUid, agentUid });
|
|
277
|
+
const agent = detail.data?.agent;
|
|
278
|
+
if (detail.status !== 'success')
|
|
279
|
+
throw new models_1.ToolingError(detail.error?.code || 'API_ERROR', detail.summary);
|
|
280
|
+
if (!agent || agent.uid !== agentUid || !agent.responseKinds.includes(responseKind))
|
|
281
|
+
throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'This agent does not support the selected response mode.');
|
|
282
|
+
const previousTask = draft.taskRequestId ? await this.get(scope, uuid(draft.taskRequestId), signal) : undefined;
|
|
283
|
+
if (previousTask && (responseKind !== 'task' || previousTask.agentUid !== agentUid || previousTask.environmentUid !== environmentUid || !previousTask.task))
|
|
284
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Continue only a saved task belonging to this agent and environment.');
|
|
285
|
+
signal?.throwIfAborted();
|
|
286
|
+
return this.locked(key, async (state) => {
|
|
287
|
+
const same = (r) => r.agentUid === agentUid && r.environmentUid === environmentUid && r.message === draft.message && (r.responseKind ?? 'message') === responseKind && r.conversationKey === conversationKey && r.taskRequestId === draft.taskRequestId;
|
|
288
|
+
const previous = state.requests[requestId];
|
|
289
|
+
if (previous) {
|
|
290
|
+
if (!same(previous))
|
|
291
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'This request ID already belongs to different immutable content.');
|
|
292
|
+
return view(previous);
|
|
293
|
+
}
|
|
294
|
+
const duplicate = Object.values(state.requests).find(r => same(r) && ['pending', 'preparing', 'sending', 'uncertain'].includes(r.state));
|
|
295
|
+
if (duplicate)
|
|
296
|
+
return view(duplicate);
|
|
297
|
+
if (Object.keys(state.requests).length >= 256)
|
|
298
|
+
throw new models_1.ToolingError('STORE_ERROR', 'This conversation reached its saved request limit. Existing receipts are retained.');
|
|
299
|
+
const request = { requestId, messageId: requestId, agentUid, environmentUid, agentName: agent.name.slice(0, 300), message: draft.message, responseKind, ...(previousTask ? { taskRequestId: previousTask.requestId, continuationTaskUid: uuid(previousTask.task.taskUid) } : {}), ...(conversationKey ? { conversationKey } : {}),
|
|
300
|
+
handleId: previousTask?.handleId ?? 'host_' + hash(conversationKey ? [key, environmentUid, agentUid, conversationKey] : [key, environmentUid, agentUid]), createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
301
|
+
state: 'pending', sendAttempts: 0, submission: { attempts: 0, outcome: 'not-submitted' } };
|
|
302
|
+
state.requests[requestId] = request;
|
|
303
|
+
return view(request);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
async list(input, signal) {
|
|
307
|
+
const { scope, key } = this.scope(input);
|
|
308
|
+
await this.verify(scope, signal);
|
|
309
|
+
return this.locked(key, async (state) => Object.values(state.requests).map(view));
|
|
310
|
+
}
|
|
311
|
+
async get(input, id, signal) {
|
|
312
|
+
const requestId = uuid(id);
|
|
313
|
+
const requests = await this.list(input, signal), request = requests.find(r => r.requestId === requestId);
|
|
314
|
+
if (!request)
|
|
315
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'No saved request with this ID belongs to this account and conversation.');
|
|
316
|
+
return request;
|
|
317
|
+
}
|
|
318
|
+
async submission(input, id, outcome, signal) {
|
|
319
|
+
if (!['attempted', 'acknowledged', 'unknown'].includes(outcome))
|
|
320
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Invalid submission outcome.');
|
|
321
|
+
const { scope, key } = this.scope(input), requestId = uuid(id);
|
|
322
|
+
await this.verify(scope, signal);
|
|
323
|
+
return this.locked(key, async (state) => {
|
|
324
|
+
const r = state.requests[requestId];
|
|
325
|
+
if (!r)
|
|
326
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown saved request.');
|
|
327
|
+
r.submission.outcome = outcome;
|
|
328
|
+
if (outcome === 'attempted')
|
|
329
|
+
r.submission.attempts++;
|
|
330
|
+
return view(r);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
async cancel(input, id, signal) {
|
|
334
|
+
const { scope, key } = this.scope(input), requestId = uuid(id);
|
|
335
|
+
await this.verify(scope, signal);
|
|
336
|
+
return this.locked(key, async (state) => {
|
|
337
|
+
const r = state.requests[requestId];
|
|
338
|
+
if (!r)
|
|
339
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown saved request.');
|
|
340
|
+
if (r.state !== 'pending' && r.state !== 'cancelled')
|
|
341
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'An accepted or completed message cannot be unsent. Read its saved status.');
|
|
342
|
+
r.state = 'cancelled';
|
|
343
|
+
r.reason = 'Cancelled before dispatch.';
|
|
344
|
+
return view(r);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
async deliver(input, id, signal) {
|
|
348
|
+
const { scope, key } = this.scope(input), requestId = uuid(id);
|
|
349
|
+
await this.verify(scope, signal);
|
|
350
|
+
const owner = { pid: process.pid, token: (0, node_crypto_1.randomUUID)() };
|
|
351
|
+
const claimed = await this.locked(key, async (state) => {
|
|
352
|
+
signal?.throwIfAborted();
|
|
353
|
+
const r = state.requests[requestId];
|
|
354
|
+
if (!r)
|
|
355
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown saved request in this conversation.');
|
|
356
|
+
// Explicit recovery may repeat preparation when the durable marker proves no send began.
|
|
357
|
+
// Ambiguous transport failures remain uncertain and are never eligible.
|
|
358
|
+
if (r.state !== 'pending' && !(r.state === 'failed' && r.sendAttempts === 0))
|
|
359
|
+
return false;
|
|
360
|
+
if (Object.values(state.requests).some(other => other.requestId !== requestId && other.agentUid === r.agentUid && other.environmentUid === r.environmentUid && ['preparing', 'sending', 'uncertain'].includes(other.state))) {
|
|
361
|
+
throw new models_1.ToolingError('PROJECT_BUSY', 'This agent conversation has a running or uncertain delivery. Recover its existing request before sending another.');
|
|
362
|
+
}
|
|
363
|
+
r.state = 'preparing';
|
|
364
|
+
r.owner = owner;
|
|
365
|
+
delete r.reason;
|
|
366
|
+
return true;
|
|
367
|
+
});
|
|
368
|
+
if (claimed) {
|
|
369
|
+
const jobKey = key + requestId;
|
|
370
|
+
const job = this.run(scope, key, requestId, owner).catch(() => { });
|
|
371
|
+
this.jobs.set(jobKey, job);
|
|
372
|
+
void job.finally(() => { if (this.jobs.get(jobKey) === job)
|
|
373
|
+
this.jobs.delete(jobKey); });
|
|
374
|
+
}
|
|
375
|
+
return this.wait(scope, requestId, signal);
|
|
376
|
+
}
|
|
377
|
+
async wait(input, id, signal, waitMs = 25000) {
|
|
378
|
+
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 30000)
|
|
379
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Wait must be between zero and 30 seconds.');
|
|
380
|
+
const { scope, key } = this.scope(input), requestId = uuid(id);
|
|
381
|
+
await this.verify(scope, signal);
|
|
382
|
+
const deadline = Date.now() + waitMs;
|
|
383
|
+
while (true) {
|
|
384
|
+
signal?.throwIfAborted();
|
|
385
|
+
const r = await this.locked(key, async (state) => { const value = state.requests[requestId]; if (!value)
|
|
386
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown saved request.'); return view(value); });
|
|
387
|
+
if (finished(r) || r.state === 'pending' || Date.now() >= deadline) {
|
|
388
|
+
await this.verify(scope, signal);
|
|
389
|
+
return r;
|
|
390
|
+
}
|
|
391
|
+
await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/** Reads/cancels only a task obtained from this chat's durable receipt; never sends a message. */
|
|
395
|
+
async task(input, id, action, signal) {
|
|
396
|
+
if (!['get', 'wait', 'cancel'].includes(action))
|
|
397
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Choose get, wait or cancel.');
|
|
398
|
+
const { scope, key } = this.scope(input), r = await this.get(input, id, signal);
|
|
399
|
+
if (!r.task || !r.sessionUid)
|
|
400
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'This request has no accepted agent task.');
|
|
401
|
+
const taskUid = uuid(r.task.taskUid);
|
|
402
|
+
const result = await this.options.createDispatch(signal)({ action: action === 'cancel' ? 'agent-task-cancel' : action === 'wait' ? 'agent-task-wait' : 'agent-task-get',
|
|
403
|
+
backend: scope.backend, expectedAccount: scope.account, environmentUid: r.environmentUid, agentUid: r.agentUid, taskUid });
|
|
404
|
+
if (result.status !== 'success')
|
|
405
|
+
throw new models_1.ToolingError(result.error?.code || 'API_ERROR', result.summary);
|
|
406
|
+
const task = result.data;
|
|
407
|
+
if (task.taskUid !== taskUid || task.sessionUid !== r.sessionUid)
|
|
408
|
+
throw new models_1.ToolingError('API_ERROR', 'The task response does not match this saved request.');
|
|
409
|
+
return this.locked(key, async (state) => {
|
|
410
|
+
const record = state.requests[r.requestId];
|
|
411
|
+
// Older responses from concurrent reads must not overwrite newer task progress.
|
|
412
|
+
if (Number(task.eventCursor ?? 0) >= Number(record.task?.eventCursor ?? 0)) {
|
|
413
|
+
record.task = task;
|
|
414
|
+
record.receipt = { completedAt: new Date().toISOString(), sessionUid: r.sessionUid, message: JSON.stringify(task) };
|
|
415
|
+
}
|
|
416
|
+
return view(record);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
async run(scope, key, id, owner) {
|
|
420
|
+
const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 12 * 60_000);
|
|
421
|
+
const dispatch = this.options.createDispatch(controller.signal);
|
|
422
|
+
const update = async (fn) => this.locked(key, async (state) => {
|
|
423
|
+
const r = state.requests[id];
|
|
424
|
+
if (r?.owner?.token !== owner.token)
|
|
425
|
+
throw failStore();
|
|
426
|
+
fn(r);
|
|
427
|
+
return view(r);
|
|
428
|
+
});
|
|
429
|
+
let sending = false;
|
|
430
|
+
try {
|
|
431
|
+
const r = await update(() => { }), selection = { backend: scope.backend, environmentUid: r.environmentUid, agentUid: r.agentUid, expectedAccount: scope.account };
|
|
432
|
+
const prepared = await dispatch({ action: 'agent-session', ...selection, handleId: r.handleId });
|
|
433
|
+
if (prepared.status !== 'success')
|
|
434
|
+
throw new models_1.ToolingError(prepared.error?.code || 'API_ERROR', prepared.summary);
|
|
435
|
+
const sessionUid = uuid(prepared.data?.sessionUid);
|
|
436
|
+
await update(record => { record.sessionUid = sessionUid; });
|
|
437
|
+
const ready = await dispatch({ action: 'agent-readiness', ...selection, sessionUid, timeoutMs: this.options.readinessTimeoutMs ?? 300000 });
|
|
438
|
+
if (ready.status !== 'success')
|
|
439
|
+
throw new models_1.ToolingError(ready.error?.code || 'API_ERROR', ready.summary);
|
|
440
|
+
controller.signal.throwIfAborted();
|
|
441
|
+
await update(record => { record.state = 'sending'; record.sendAttempts = 1; });
|
|
442
|
+
sending = true;
|
|
443
|
+
const result = await dispatch({ action: 'agent-send', ...selection, ...(r.continuationTaskUid ? { taskUid: r.continuationTaskUid } : { sessionUid }), message: r.message, messageId: r.messageId, responseKind: r.responseKind ?? 'message' });
|
|
444
|
+
const reply = result.data;
|
|
445
|
+
await update(record => {
|
|
446
|
+
if (result.status === 'success' && reply?.responseKind === 'task' && r.responseKind === 'task' && reply.sessionUid === sessionUid && reply.taskUid) {
|
|
447
|
+
uuid(reply.taskUid);
|
|
448
|
+
record.state = 'delivered';
|
|
449
|
+
record.task = reply;
|
|
450
|
+
record.receipt = { completedAt: new Date().toISOString(), sessionUid, message: JSON.stringify(reply) };
|
|
451
|
+
}
|
|
452
|
+
else if (result.status === 'success' && reply?.responseKind === 'message' && typeof reply.message === 'string' && reply.message.trim() && reply.sessionUid === sessionUid) {
|
|
453
|
+
record.state = 'delivered';
|
|
454
|
+
record.receipt = { completedAt: new Date().toISOString(), sessionUid, message: reply.message };
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
record.state = result.effects || result.status === 'success' ? 'uncertain' : 'failed';
|
|
458
|
+
if (record.state === 'failed')
|
|
459
|
+
record.sendAttempts = 0; // AgentClient proved its preflight failed before a2a.send_message.
|
|
460
|
+
record.reason = record.state === 'uncertain' ? 'The agent may have received this message. Resend is blocked.' : result.summary.slice(0, 2000);
|
|
461
|
+
}
|
|
462
|
+
delete record.owner;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
await update(record => {
|
|
467
|
+
record.state = sending ? 'uncertain' : 'failed';
|
|
468
|
+
record.reason = sending ? 'Dispatch did not produce a durable reply. Resend is blocked.' : error instanceof models_1.ToolingError ? error.message.slice(0, 2000) : 'Preparation failed before sending. No agent message was sent.';
|
|
469
|
+
delete record.owner;
|
|
470
|
+
}).catch(() => { });
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
clearTimeout(timer);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
exports.AgentDelivery = AgentDelivery;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stillAgentPng = stillAgentPng;
|
|
4
|
+
exports.inlineAgentAsset = inlineAgentAsset;
|
|
5
|
+
/** APNG's default image is frame zero. Hosts choose it when reduced motion is enabled. */
|
|
6
|
+
function stillAgentPng(bytes) {
|
|
7
|
+
if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
|
8
|
+
return bytes;
|
|
9
|
+
const parts = [bytes.subarray(0, 8)];
|
|
10
|
+
let offset = 8;
|
|
11
|
+
while (offset + 12 <= bytes.length) {
|
|
12
|
+
const end = offset + 12 + bytes.readUInt32BE(offset), type = bytes.toString('ascii', offset + 4, offset + 8);
|
|
13
|
+
if (end > bytes.length)
|
|
14
|
+
break;
|
|
15
|
+
if (!['acTL', 'fcTL', 'fdAT'].includes(type))
|
|
16
|
+
parts.push(bytes.subarray(offset, end));
|
|
17
|
+
offset = end;
|
|
18
|
+
if (type === 'IEND' && end === bytes.length)
|
|
19
|
+
return Buffer.concat(parts);
|
|
20
|
+
}
|
|
21
|
+
throw new Error('The agent icon is incomplete.');
|
|
22
|
+
}
|
|
23
|
+
/** Only bounded inline bytes cross into the host. Never fetch the icon URL. */
|
|
24
|
+
function inlineAgentAsset(value) {
|
|
25
|
+
if (!value || typeof value !== 'object')
|
|
26
|
+
return null;
|
|
27
|
+
const icon = value, idle = icon.idle;
|
|
28
|
+
if (!['mask', 'color'].includes(icon.rendering) || !idle || !['image/png', 'image/svg+xml'].includes(idle.mime)
|
|
29
|
+
|| typeof idle.bytes !== 'string' || !idle.bytes.length || idle.bytes.length > 699052
|
|
30
|
+
|| idle.bytes.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(idle.bytes))
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const bytes = Buffer.from(idle.bytes, 'base64');
|
|
34
|
+
if (!bytes.length || bytes.length > 524288 || bytes.toString('base64') !== idle.bytes)
|
|
35
|
+
return null;
|
|
36
|
+
const uri = (data) => `data:${idle.mime};base64,${data.toString('base64')}`;
|
|
37
|
+
return { source: uri(bytes), stillSource: uri(idle.mime === 'image/png' ? stillAgentPng(bytes) : bytes),
|
|
38
|
+
rendering: icon.rendering, imageRendering: icon.imageRendering === 'pixelated' ? 'pixelated' : 'auto' };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -7,6 +7,7 @@ const models_1 = require("./models.js");
|
|
|
7
7
|
const session_1 = require("./session.js");
|
|
8
8
|
const mcpTransport_1 = require("./mcpTransport.js");
|
|
9
9
|
const agentReadiness_1 = require("./agentReadiness.js");
|
|
10
|
+
const agentIcon_1 = require("./agentIcon.js");
|
|
10
11
|
function isAgentAction(action) {
|
|
11
12
|
return ['agents-list', 'agent-get', 'agent-session', 'agent-readiness', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(action);
|
|
12
13
|
}
|
|
@@ -17,12 +18,13 @@ function uuid(value) {
|
|
|
17
18
|
return value.toLowerCase();
|
|
18
19
|
}
|
|
19
20
|
function text(value, limit = 16000) { return typeof value === 'string' ? value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, limit) : ''; }
|
|
20
|
-
function agent(row, environmentUid) {
|
|
21
|
+
function agent(row, environmentUid, includeIcons = false) {
|
|
21
22
|
if (!row || uuid(row.organization_environment_uid) !== environmentUid)
|
|
22
23
|
throw new models_1.ToolingError('INVALID_REQUEST', 'This agent belongs to a different environment. Refresh the directory.');
|
|
23
24
|
if (!text(row.name).trim())
|
|
24
25
|
throw new models_1.ToolingError('API_ERROR', 'The agent directory returned an unnamed agent.');
|
|
25
26
|
return { uid: uuid(row.uid), name: text(row.name, 300), description: text(row.description), environmentUid,
|
|
27
|
+
...(includeIcons ? { icon: (0, agentIcon_1.inlineAgentAsset)(row.command_center_icon) } : {}),
|
|
26
28
|
environmentName: text(row.organization_environment_name, 300), repositoryBranch: text(row.repository_branch, 300),
|
|
27
29
|
runtimeReleaseUid: row.runtime_release_uid ? uuid(row.runtime_release_uid) : null,
|
|
28
30
|
runtimeState: text(row.runtime_update?.state, 50) || 'unknown',
|
|
@@ -46,6 +48,8 @@ class AgentClient {
|
|
|
46
48
|
this.options.signal?.throwIfAborted();
|
|
47
49
|
if (!isAgentAction(input.action))
|
|
48
50
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown agent action.');
|
|
51
|
+
if ('includeIcons' in input && input.includeIcons !== undefined && typeof input.includeIcons !== 'boolean')
|
|
52
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'includeIcons must be a boolean.');
|
|
49
53
|
const identity = await this.session.status(backend, true);
|
|
50
54
|
if (!identity.authenticated || !identity.username)
|
|
51
55
|
throw new models_1.ToolingError('NOT_AUTHENTICATED', 'Sign in to MainSequence to use agents.');
|
|
@@ -54,12 +58,16 @@ class AgentClient {
|
|
|
54
58
|
if (['agent-session', 'agent-readiness', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(input.action) && !input.expectedAccount) {
|
|
55
59
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Agent conversations must be bound to the account returned by discovery.');
|
|
56
60
|
}
|
|
61
|
+
// The built-in session binds every request/retry to the selected account. Legacy
|
|
62
|
+
// custom agent transports retain their existing account-check contract.
|
|
63
|
+
const session = input.expectedAccount !== undefined && this.session.withAccount
|
|
64
|
+
? await this.session.withAccount(backend, input.expectedAccount) : this.session;
|
|
57
65
|
const timeouts = new Map();
|
|
58
66
|
const client = new mcpTransport_1.McpTransport(message => {
|
|
59
67
|
const remaining = readinessDeadline === undefined ? undefined : Math.ceil(readinessDeadline - performance.now());
|
|
60
68
|
if (remaining !== undefined && remaining <= 0)
|
|
61
69
|
throw new models_1.ToolingError('PROCESS_TIMEOUT', 'The agent did not become ready in time. No message was sent. Try again.');
|
|
62
|
-
return
|
|
70
|
+
return session.request(backend, 'POST', '/mcp', message, message.method !== 'tools/call' || !['a2a.send_message', 'a2a.cancel_task'].includes(message.params?.name), remaining !== undefined ? { timeoutMs: Math.min(30000, remaining) }
|
|
63
71
|
: message.method === 'tools/call' && timeouts.has(message.params?.name) ? { timeoutMs: timeouts.get(message.params.name) } : undefined);
|
|
64
72
|
});
|
|
65
73
|
await client.initialize();
|
|
@@ -96,15 +104,17 @@ class AgentClient {
|
|
|
96
104
|
|| (input.search !== undefined && (typeof input.search !== 'string' || input.search.length > 2000)))
|
|
97
105
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Search requires text, a limit of 1–100, and a nonnegative offset.');
|
|
98
106
|
const search = input.search?.trim();
|
|
99
|
-
const
|
|
100
|
-
|
|
107
|
+
const icons = input.includeIcons ? { include_icons: true } : {};
|
|
108
|
+
const page = search ? await call('agent.search', { organization_environment_uid: environmentUid, q: search, limit, ...icons })
|
|
109
|
+
: await call('agent.list', { organization_environment_uid: environmentUid, limit, offset, ...icons });
|
|
101
110
|
if (!Array.isArray(page.results) || (!search && (!Number.isInteger(page.count) || page.count < 0)))
|
|
102
111
|
throw new models_1.ToolingError('API_ERROR', 'The platform returned invalid agent pagination.');
|
|
103
|
-
data = { account: identity.username, results: page.results.map((row) => agent(row, environmentUid)), count: search ? page.results.length : page.count, offset: search ? 0 : offset, semantic: !!search };
|
|
112
|
+
data = { account: identity.username, results: page.results.map((row) => agent(row, environmentUid, input.includeIcons)), count: search ? page.results.length : page.count, offset: search ? 0 : offset, semantic: !!search };
|
|
104
113
|
}
|
|
105
114
|
else {
|
|
106
115
|
const agentUid = uuid(input.agentUid);
|
|
107
|
-
const
|
|
116
|
+
const includeIcons = input.action === 'agent-get' && input.includeIcons === true;
|
|
117
|
+
const selected = agent(await call('agent.get', { agent_uid: agentUid, ...(includeIcons ? { include_icons: true } : {}) }), environmentUid, includeIcons);
|
|
108
118
|
if (selected.uid !== agentUid)
|
|
109
119
|
throw new models_1.ToolingError('API_ERROR', 'The platform returned another agent.');
|
|
110
120
|
const task = async (taskUid) => {
|
|
@@ -164,6 +174,7 @@ class AgentClient {
|
|
|
164
174
|
...(resolved.bound_handle?.handle_unique_id ? { handleId: text(resolved.bound_handle.handle_unique_id, 255) } : {}) };
|
|
165
175
|
}
|
|
166
176
|
else if (input.action === 'agent-send') {
|
|
177
|
+
const messageId = input.messageId === undefined ? (0, node_crypto_1.randomUUID)() : uuid(input.messageId);
|
|
167
178
|
if (typeof input.message !== 'string' || !input.message.trim() || input.message.length > 64000)
|
|
168
179
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Enter a message of 1–64000 characters.');
|
|
169
180
|
if (!['message', 'task'].includes(input.responseKind) || !selected.responseKinds.includes(input.responseKind))
|
|
@@ -194,7 +205,7 @@ class AgentClient {
|
|
|
194
205
|
: input.sessionUid ? { session_uid: uuid(input.sessionUid) } : { handle_unique_id: input.handleId });
|
|
195
206
|
await ready(uuid(resolved.uid));
|
|
196
207
|
}
|
|
197
|
-
const reply = await call('a2a.send_message', { ...target, message: input.message, message_id:
|
|
208
|
+
const reply = await call('a2a.send_message', { ...target, message: input.message, message_id: messageId, response_kind: input.responseKind,
|
|
198
209
|
...(input.responseKind === 'task' ? { completion_policy: 'poll' } : {}) }, true);
|
|
199
210
|
if (uuid(reply.agent_uid) !== agentUid || reply.response_kind !== input.responseKind || (input.sessionUid && uuid(reply.agent_session_uid) !== uuid(input.sessionUid)))
|
|
200
211
|
throw new models_1.ToolingError('API_ERROR', 'The agent response does not match this conversation.');
|