@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
|
@@ -34,19 +34,50 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.McpConnections = void 0;
|
|
37
|
+
exports.createMcpConnectionCache = createMcpConnectionCache;
|
|
37
38
|
const fs = __importStar(require("node:fs/promises"));
|
|
38
39
|
const path = __importStar(require("node:path"));
|
|
39
|
-
const os = __importStar(require("node:os"));
|
|
40
40
|
const node_crypto_1 = require("node:crypto");
|
|
41
41
|
const models_1 = require("./models.js");
|
|
42
42
|
const session_1 = require("./session.js");
|
|
43
43
|
const mcpTransport_1 = require("./mcpTransport.js");
|
|
44
|
+
const stateDirectory_1 = require("./stateDirectory.js");
|
|
45
|
+
const projectTools_1 = require("./projectTools.js");
|
|
46
|
+
const repositoryModels_1 = require("./repositoryModels.js");
|
|
47
|
+
const environments_1 = require("./environments.js");
|
|
48
|
+
const accountScope_1 = require("./accountScope.js");
|
|
49
|
+
const caches = new WeakMap();
|
|
50
|
+
function createMcpConnectionCache() {
|
|
51
|
+
const cache = Object.freeze({ kind: 'mainsequence-mcp-cache' });
|
|
52
|
+
caches.set(cache, new Map());
|
|
53
|
+
return cache;
|
|
54
|
+
}
|
|
55
|
+
const cacheKey = (backend, uid) => JSON.stringify([backend, uid]);
|
|
56
|
+
const savedIdentity = (value) => JSON.stringify([value.backend, value.resource_release_uid, value.mcp_connection]);
|
|
57
|
+
function credentialExpiry(token) {
|
|
58
|
+
// This is only a conservative cache lifetime hint; the server verifies the JWT.
|
|
59
|
+
try {
|
|
60
|
+
const exp = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()).exp;
|
|
61
|
+
return typeof exp === 'number' && Number.isFinite(exp) ? exp * 1000 : 0;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
44
68
|
function uuid(value) {
|
|
45
|
-
if (typeof value !== 'string' ||
|
|
69
|
+
if (typeof value !== 'string' || !UUID.test(value)) {
|
|
46
70
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Select an exact resource release UUID from MCP discovery or saved connections.');
|
|
47
71
|
}
|
|
48
72
|
return value.toLowerCase();
|
|
49
73
|
}
|
|
74
|
+
/** Optional labels: an absent or malformed value is dropped, never an error. */
|
|
75
|
+
function labels(row) {
|
|
76
|
+
const text = (value) => typeof value === 'string' && value.trim() && !/[\r\n\0]/.test(value) ? value.trim() : undefined;
|
|
77
|
+
const name = text(row?.name), display = text(row?.display_name);
|
|
78
|
+
const branch = typeof row?.code_repository_branch_uid === 'string' && UUID.test(row.code_repository_branch_uid) ? row.code_repository_branch_uid.toLowerCase() : undefined;
|
|
79
|
+
return { ...(name ? { name } : {}), ...(display ? { display_name: display } : {}), ...(branch ? { code_repository_branch_uid: branch } : {}) };
|
|
80
|
+
}
|
|
50
81
|
function endpoint(value, metadata = false) {
|
|
51
82
|
if (typeof value !== 'string')
|
|
52
83
|
throw new models_1.ToolingError('API_ERROR', 'The backend returned no MCP endpoint.');
|
|
@@ -76,27 +107,27 @@ function connection(backend, row) {
|
|
|
76
107
|
if ([...metadataQuery.keys()].some(key => key !== 'resource_release_uid') || metadataQuery.getAll('resource_release_uid').length !== 1
|
|
77
108
|
|| metadataQuery.get('resource_release_uid') !== releaseUid)
|
|
78
109
|
throw new models_1.ToolingError('API_ERROR', 'The MCP metadata targets another release.');
|
|
79
|
-
return { backend: (0, session_1.requireBackend)(backend), resource_release_uid: releaseUid,
|
|
80
|
-
...(typeof row.name === 'string' && row.name.trim() ? { name: row.name } : {}),
|
|
110
|
+
return { backend: (0, session_1.requireBackend)(backend), resource_release_uid: releaseUid, ...labels(row),
|
|
81
111
|
mcp_connection: { url, transport: 'streamable-http', resource_metadata_url: metadataUrl,
|
|
82
112
|
organization_environment_uid: uuid(c.organization_environment_uid),
|
|
83
113
|
...(typeof c.organization_environment_name === 'string' && c.organization_environment_name.trim() ? { organization_environment_name: c.organization_environment_name.trim() } : {}), active_revision_uid: uuid(c.active_revision_uid) } };
|
|
84
114
|
}
|
|
85
115
|
function storagePath(env) {
|
|
86
|
-
|
|
87
|
-
: process.platform === 'win32' ? path.join(env.APPDATA || os.homedir(), 'MainSequenceCLI') : path.join(os.homedir(), '.config', 'mainsequence');
|
|
88
|
-
return path.join(dir, 'mcp-connections.json');
|
|
116
|
+
return path.join((0, stateDirectory_1.mainSequenceStateDirectory)(env), 'mcp-connections.json');
|
|
89
117
|
}
|
|
90
|
-
/** Only endpoint metadata is persisted.
|
|
118
|
+
/** Only endpoint metadata is persisted. A bounded runtime may reuse an in-memory grant. */
|
|
91
119
|
class McpConnections {
|
|
92
|
-
|
|
120
|
+
originalSession;
|
|
93
121
|
options;
|
|
94
122
|
file;
|
|
95
123
|
secrets = new Set();
|
|
96
|
-
|
|
97
|
-
|
|
124
|
+
session;
|
|
125
|
+
checkAccount;
|
|
126
|
+
constructor(originalSession, options = {}) {
|
|
127
|
+
this.originalSession = originalSession;
|
|
98
128
|
this.options = options;
|
|
99
129
|
this.file = options.connectionsPath ?? storagePath(options.env ?? process.env);
|
|
130
|
+
this.session = originalSession;
|
|
100
131
|
}
|
|
101
132
|
sanitize(value) {
|
|
102
133
|
if (typeof value === 'string') {
|
|
@@ -128,6 +159,7 @@ class McpConnections {
|
|
|
128
159
|
}
|
|
129
160
|
async update(change) {
|
|
130
161
|
this.options.signal?.throwIfAborted();
|
|
162
|
+
await this.checkAccount?.();
|
|
131
163
|
await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
132
164
|
const lock = this.file + '.lock', temporary = this.file + '.' + (0, node_crypto_1.randomUUID)() + '.tmp';
|
|
133
165
|
let handle;
|
|
@@ -165,6 +197,16 @@ class McpConnections {
|
|
|
165
197
|
throw new models_1.ToolingError('API_ERROR', 'The backend returned a different release.');
|
|
166
198
|
return value;
|
|
167
199
|
}
|
|
200
|
+
get projectTimeoutMs() { return this.options.projectRequestTimeoutMs ?? projectTools_1.DEFAULT_PROJECT_REQUEST_TIMEOUT_MS; }
|
|
201
|
+
client(value, token, initialized = false) {
|
|
202
|
+
this.secrets.add(token);
|
|
203
|
+
return new mcpTransport_1.McpTransport(async (message, request) => {
|
|
204
|
+
await this.checkAccount?.();
|
|
205
|
+
const result = await (0, mcpTransport_1.sendProjectMcp)(value.mcp_connection.url, token, message, { fetch: this.options.fetch, signal: this.options.signal, timeoutMs: request?.timeoutMs ?? this.projectTimeoutMs });
|
|
206
|
+
await this.checkAccount?.();
|
|
207
|
+
return result;
|
|
208
|
+
}, initialized);
|
|
209
|
+
}
|
|
168
210
|
async open(value) {
|
|
169
211
|
const grant = await this.session.request(value.backend, 'GET', '/api/v1/resource-releases/' + value.resource_release_uid + '/exchange-launch/');
|
|
170
212
|
if (grant?.mode !== 'token' || grant.release_kind !== 'fastapi' || typeof grant.token !== 'string' || !grant.token || /[\r\n]/.test(grant.token)) {
|
|
@@ -174,15 +216,153 @@ class McpConnections {
|
|
|
174
216
|
if (new URL(endpoint(grant.rpc_url)).origin !== new URL(value.mcp_connection.url).origin) {
|
|
175
217
|
throw new models_1.ToolingError('API_ERROR', 'The release credential targets a different endpoint.');
|
|
176
218
|
}
|
|
177
|
-
const client =
|
|
219
|
+
const client = this.client(value, grant.token);
|
|
178
220
|
await client.initialize();
|
|
179
|
-
return client;
|
|
221
|
+
return { client, token: grant.token, expiresAt: credentialExpiry(grant.token) };
|
|
222
|
+
}
|
|
223
|
+
/** The release's name and repository from the release directory and its branch. Whatever
|
|
224
|
+
* cannot be read is left out: labels never make a working MCP unavailable. */
|
|
225
|
+
async resolveLabels(value) {
|
|
226
|
+
const found = {};
|
|
227
|
+
const read = async (apiPath) => {
|
|
228
|
+
try {
|
|
229
|
+
return await this.session.request(value.backend, 'GET', apiPath, undefined, true);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
this.options.signal?.throwIfAborted();
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
// The release detail carries no name or repository; the directory projection does.
|
|
237
|
+
const page = await read('/api/v1/resource-releases/?search=' + encodeURIComponent(value.resource_release_uid) + '&limit=100');
|
|
238
|
+
const row = Array.isArray(page?.results) ? page.results.find((item) => typeof item?.uid === 'string' && item.uid.toLowerCase() === value.resource_release_uid) : undefined;
|
|
239
|
+
if (row)
|
|
240
|
+
Object.assign(found, labels({ name: row.name, code_repository_branch_uid: row.code_repository_branch_uid }));
|
|
241
|
+
const branchUid = found.code_repository_branch_uid ?? value.code_repository_branch_uid;
|
|
242
|
+
if (branchUid) {
|
|
243
|
+
const branch = await read('/api/v1/code-repository-branches/' + encodeURIComponent(branchUid) + '/');
|
|
244
|
+
try {
|
|
245
|
+
const parsed = (0, repositoryModels_1.codeRepositoryBranchFromResponse)(branch, 'CodeRepositoryBranch response');
|
|
246
|
+
if (parsed.uid.toLowerCase() === branchUid)
|
|
247
|
+
Object.assign(found, labels({ display_name: parsed.code_repository_name, code_repository_branch_uid: branchUid }));
|
|
248
|
+
}
|
|
249
|
+
catch { /* an unreadable branch leaves the repository name out */ }
|
|
250
|
+
}
|
|
251
|
+
return found;
|
|
252
|
+
}
|
|
253
|
+
labelled(value, found) {
|
|
254
|
+
return connection(value.backend, { ...value, ...found });
|
|
255
|
+
}
|
|
256
|
+
/** Optional discovery labels, deduplicated by branch. Never writes connection metadata. */
|
|
257
|
+
async discoveryNames(rows) {
|
|
258
|
+
const pending = [...new Set(rows.filter(row => !row.display_name).flatMap(row => row.code_repository_branch_uid ? [row.code_repository_branch_uid] : []))];
|
|
259
|
+
const names = new Map();
|
|
260
|
+
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
|
|
261
|
+
while (pending.length) {
|
|
262
|
+
this.options.signal?.throwIfAborted();
|
|
263
|
+
const uid = pending.shift();
|
|
264
|
+
try {
|
|
265
|
+
const raw = await this.session.request(rows[0].backend, 'GET', '/api/v1/code-repository-branches/' + uid + '/');
|
|
266
|
+
const branch = (0, repositoryModels_1.codeRepositoryBranchFromResponse)(raw, 'CodeRepositoryBranch response');
|
|
267
|
+
if (branch.uid.toLowerCase() === uid)
|
|
268
|
+
names.set(uid, branch.code_repository_name);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
this.options.signal?.throwIfAborted();
|
|
272
|
+
if (error instanceof models_1.ToolingError && error.code === 'CANCELLED')
|
|
273
|
+
throw error;
|
|
274
|
+
await this.checkAccount?.();
|
|
275
|
+
// Unavailable optional names must not hide usable endpoints.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}));
|
|
279
|
+
return rows.map(row => !row.display_name && row.code_repository_branch_uid && names.has(row.code_repository_branch_uid)
|
|
280
|
+
? this.labelled(row, { display_name: names.get(row.code_repository_branch_uid) }) : row);
|
|
281
|
+
}
|
|
282
|
+
/** Records saved before labels were stored are completed once, when the backend answers. */
|
|
283
|
+
async backfill(rows) {
|
|
284
|
+
const pending = rows.filter(row => !row.display_name);
|
|
285
|
+
if (!pending.length)
|
|
286
|
+
return rows;
|
|
287
|
+
const found = new Map();
|
|
288
|
+
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
|
|
289
|
+
while (pending.length) {
|
|
290
|
+
const row = pending.shift();
|
|
291
|
+
const resolved = await this.resolveLabels(row);
|
|
292
|
+
const added = Object.entries(resolved).filter(([key, item]) => item !== row[key]);
|
|
293
|
+
if (added.length)
|
|
294
|
+
found.set(row.backend + ' ' + row.resource_release_uid, Object.fromEntries(added));
|
|
295
|
+
}
|
|
296
|
+
}));
|
|
297
|
+
if (!found.size)
|
|
298
|
+
return rows;
|
|
299
|
+
const key = (row) => row.backend + ' ' + row.resource_release_uid;
|
|
300
|
+
const merge = (row) => found.has(key(row)) ? this.labelled(row, { ...found.get(key(row)) }) : row;
|
|
301
|
+
try {
|
|
302
|
+
await this.update(all => all.map(merge));
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
// A held lock or a failed write keeps the listing itself available.
|
|
306
|
+
if (error instanceof models_1.ToolingError && error.code === 'CANCELLED')
|
|
307
|
+
throw error;
|
|
308
|
+
this.options.signal?.throwIfAborted();
|
|
309
|
+
}
|
|
310
|
+
return rows.map(merge);
|
|
180
311
|
}
|
|
181
312
|
async dispatch(request, inputBackend) {
|
|
182
313
|
let callStarted = false, resultData;
|
|
314
|
+
let key, reused = false;
|
|
315
|
+
let retained;
|
|
316
|
+
const cache = this.options.cache ? caches.get(this.options.cache) : undefined;
|
|
183
317
|
try {
|
|
184
318
|
const backend = (0, session_1.requireBackend)(inputBackend);
|
|
185
319
|
this.options.signal?.throwIfAborted();
|
|
320
|
+
if (request.action === 'mcp-catalog' || request.action === 'mcp-tool-call')
|
|
321
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'mcp-catalog and mcp-tool-call are dispatched by createDevTools(), not by the connection client.');
|
|
322
|
+
let cached, selected;
|
|
323
|
+
if ('releaseUid' in request) {
|
|
324
|
+
key = cacheKey(backend, uuid(request.releaseUid));
|
|
325
|
+
if (cache && request.action === 'mcp-remove')
|
|
326
|
+
cache.delete(key);
|
|
327
|
+
else if (cache) {
|
|
328
|
+
for (const [id, item] of cache)
|
|
329
|
+
if (item.expiresAt <= Date.now())
|
|
330
|
+
cache.delete(id);
|
|
331
|
+
selected = await this.originalSession.selectionIdentity?.(backend);
|
|
332
|
+
cached = cache.get(key);
|
|
333
|
+
if (cached && (cached.identity !== selected?.key || cached.account !== request.expectedAccount || !['mcp-tools', 'mcp-call'].includes(request.action))) {
|
|
334
|
+
cache.delete(key);
|
|
335
|
+
cached = undefined;
|
|
336
|
+
}
|
|
337
|
+
if (cached) {
|
|
338
|
+
const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === request.releaseUid);
|
|
339
|
+
if (!saved || savedIdentity(saved) !== cached.savedIdentity) {
|
|
340
|
+
cache.delete(key);
|
|
341
|
+
cached = undefined;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
reused = Boolean(cached);
|
|
347
|
+
if (cached) {
|
|
348
|
+
const identity = cached.identity;
|
|
349
|
+
this.checkAccount = async () => {
|
|
350
|
+
this.options.signal?.throwIfAborted();
|
|
351
|
+
if ((await this.originalSession.selectionIdentity?.(backend))?.key !== identity)
|
|
352
|
+
throw new models_1.ToolingError('AUTH_REJECTED', 'The shared MainSequence login changed. Refresh the current view.');
|
|
353
|
+
};
|
|
354
|
+
await this.checkAccount();
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
this.session = await (0, accountScope_1.bindSessionAccount)(this.originalSession, backend, request.expectedAccount);
|
|
358
|
+
this.checkAccount = request.expectedAccount === undefined ? undefined : (fresh = false) => this.session.status(backend, fresh).then(() => { });
|
|
359
|
+
if (cache && key)
|
|
360
|
+
selected = await this.originalSession.selectionIdentity?.(backend);
|
|
361
|
+
}
|
|
362
|
+
if (request.action === 'mcp-list' && request.readOnly !== undefined && typeof request.readOnly !== 'boolean'
|
|
363
|
+
|| request.action === 'mcp-discover' && request.enrichNames !== undefined && typeof request.enrichNames !== 'boolean') {
|
|
364
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Discovery and listing options must be booleans.');
|
|
365
|
+
}
|
|
186
366
|
const environmentUid = request.environmentUid === undefined ? undefined : uuid(request.environmentUid);
|
|
187
367
|
const belongs = (value) => !environmentUid || value.mcp_connection.organization_environment_uid === environmentUid;
|
|
188
368
|
const requireEnvironment = (value) => {
|
|
@@ -191,31 +371,7 @@ class McpConnections {
|
|
|
191
371
|
};
|
|
192
372
|
let data, summary;
|
|
193
373
|
if (request.action === 'mcp-environments') {
|
|
194
|
-
|
|
195
|
-
for (let offset = 0;; offset += 100) {
|
|
196
|
-
this.options.signal?.throwIfAborted();
|
|
197
|
-
if (offset >= 10000)
|
|
198
|
-
throw new models_1.ToolingError('API_ERROR', 'The environment list is too large.');
|
|
199
|
-
const page = await this.session.request(backend, 'GET', `/api/v1/organization-environments/?limit=100&offset=${offset}`);
|
|
200
|
-
const items = Array.isArray(page) ? page : page?.results;
|
|
201
|
-
if (!Array.isArray(items))
|
|
202
|
-
throw new models_1.ToolingError('API_ERROR', 'The backend returned an invalid environment list.');
|
|
203
|
-
for (const row of items) {
|
|
204
|
-
if (typeof row?.name !== 'string' || !row.name.trim())
|
|
205
|
-
throw new models_1.ToolingError('API_ERROR', 'An organization environment has no name.');
|
|
206
|
-
const uid = uuid(row.uid);
|
|
207
|
-
environments.set(uid, { uid, name: row.name.trim(), isProduction: row.is_production === true });
|
|
208
|
-
}
|
|
209
|
-
if (Array.isArray(page))
|
|
210
|
-
break;
|
|
211
|
-
if (!Number.isInteger(page.count) || page.count < 0)
|
|
212
|
-
throw new models_1.ToolingError('API_ERROR', 'The environment pagination is invalid.');
|
|
213
|
-
if (offset + 100 >= page.count)
|
|
214
|
-
break;
|
|
215
|
-
if (!items.length)
|
|
216
|
-
throw new models_1.ToolingError('API_ERROR', 'The environment directory changed; refresh it.');
|
|
217
|
-
}
|
|
218
|
-
data = [...environments.values()];
|
|
374
|
+
data = await (0, environments_1.listEnvironments)(this.session, backend, this.options.signal);
|
|
219
375
|
summary = 'Organization environments available to your MainSequence account.';
|
|
220
376
|
}
|
|
221
377
|
else if (request.action === 'mcp-discover') {
|
|
@@ -255,10 +411,15 @@ class McpConnections {
|
|
|
255
411
|
const values = [...matching.values()], offset = request.offset ?? 0;
|
|
256
412
|
data = { count: values.length, results: values.slice(offset, offset + (request.limit ?? 100)) };
|
|
257
413
|
}
|
|
414
|
+
if (request.enrichNames) {
|
|
415
|
+
const page = data;
|
|
416
|
+
data = { ...page, results: await this.discoveryNames(page.results) };
|
|
417
|
+
}
|
|
258
418
|
summary = 'Available project MCP endpoints; discovery does not save a connection.';
|
|
259
419
|
}
|
|
260
420
|
else if (request.action === 'mcp-list') {
|
|
261
|
-
|
|
421
|
+
const saved = (await this.read()).filter(row => row.backend === backend && belongs(row));
|
|
422
|
+
data = request.readOnly ? saved : await this.backfill(saved);
|
|
262
423
|
summary = 'Saved MCP connections for this backend. Availability is checked when used.';
|
|
263
424
|
}
|
|
264
425
|
else {
|
|
@@ -272,7 +433,8 @@ class McpConnections {
|
|
|
272
433
|
summary = 'The saved connection was removed.';
|
|
273
434
|
}
|
|
274
435
|
else {
|
|
275
|
-
|
|
436
|
+
let value = cached?.value ?? await this.current(backend, releaseUid);
|
|
437
|
+
let savedSnapshot = savedIdentity(value);
|
|
276
438
|
requireEnvironment(value);
|
|
277
439
|
if (request.action !== 'mcp-add') {
|
|
278
440
|
const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid);
|
|
@@ -280,10 +442,17 @@ class McpConnections {
|
|
|
280
442
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Add this MCP connection before using it.');
|
|
281
443
|
if (saved.mcp_connection.url !== value.mcp_connection.url)
|
|
282
444
|
throw new models_1.ToolingError('INVALID_REQUEST', 'The MCP endpoint changed. Add the release again to confirm its new endpoint.');
|
|
445
|
+
savedSnapshot = savedIdentity(saved);
|
|
446
|
+
value = this.labelled(value, labels(saved));
|
|
283
447
|
}
|
|
284
|
-
const
|
|
285
|
-
const
|
|
448
|
+
const opened = cached ? { client: this.client(value, cached.token, true), token: cached.token, expiresAt: cached.expiresAt } : await this.open(value);
|
|
449
|
+
const client = opened.client;
|
|
450
|
+
// An explicit catalog read always refreshes tools. A following call reuses it.
|
|
451
|
+
let tools = cached && request.action === 'mcp-call' ? cached.tools : await client.tools();
|
|
452
|
+
if (cached && request.action === 'mcp-call' && !tools.some(tool => tool.name === request.tool))
|
|
453
|
+
tools = await client.tools();
|
|
286
454
|
if (request.action === 'mcp-add') {
|
|
455
|
+
value = this.labelled(value, await this.resolveLabels(value));
|
|
287
456
|
await this.update(rows => [...rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid)), value]);
|
|
288
457
|
data = { connection: value, tools };
|
|
289
458
|
summary = 'MCP connection verified and saved. Its tools can now be used through the plugin.';
|
|
@@ -299,26 +468,48 @@ class McpConnections {
|
|
|
299
468
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Tool arguments must be a JSON object.');
|
|
300
469
|
this.options.signal?.throwIfAborted();
|
|
301
470
|
callStarted = true;
|
|
302
|
-
|
|
471
|
+
// A tool that advertises a longer run (`_meta["mainsequence.ai/timeout-seconds/v1"]`) gets it, capped at 300 s.
|
|
472
|
+
const output = await client.request('tools/call', { name: request.tool, arguments: request.arguments }, { timeoutMs: (0, projectTools_1.projectToolTimeoutMs)(tools.find(tool => tool.name === request.tool), this.projectTimeoutMs) });
|
|
303
473
|
if (!output || !Array.isArray(output.content))
|
|
304
474
|
throw new models_1.ToolingError('API_ERROR', 'The MCP tool returned an invalid result.');
|
|
305
475
|
resultData = output;
|
|
306
|
-
if (output.isError)
|
|
307
|
-
|
|
476
|
+
if (output.isError) {
|
|
477
|
+
await this.checkAccount?.(!reused);
|
|
478
|
+
throw new models_1.ToolingError('MCP_TOOL_ERROR', 'The connected MCP tool returned an error. Its original content and structuredContent are included in the result.');
|
|
479
|
+
}
|
|
308
480
|
data = output;
|
|
309
481
|
summary = 'The MCP tool completed.';
|
|
310
482
|
}
|
|
311
483
|
else
|
|
312
484
|
throw new models_1.ToolingError('INVALID_REQUEST', 'Unknown MCP action.');
|
|
485
|
+
if (cache && key && selected && (await this.originalSession.selectionIdentity?.(backend))?.key === selected.key) {
|
|
486
|
+
// Check local selection again before retaining a grant; never extend a lease
|
|
487
|
+
// on a warm call. Unknown/expired credential lifetimes are not cached.
|
|
488
|
+
// A cold setup may renew the same account while resolving labels or
|
|
489
|
+
// checking access. The account-bound checks below remain authoritative;
|
|
490
|
+
// skip retaining an old identity instead of failing an already saved connection.
|
|
491
|
+
const expiresAt = cached?.expiresAt ?? Math.min(Date.now() + 60_000, opened.expiresAt - 5_000, (selected.expiresAt ?? Infinity) - 5_000);
|
|
492
|
+
if (expiresAt > Date.now()) {
|
|
493
|
+
retained = { value, token: opened.token, tools, identity: selected.key, account: request.expectedAccount, expiresAt, savedIdentity: savedSnapshot };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
313
496
|
}
|
|
314
497
|
}
|
|
498
|
+
await this.checkAccount?.(!reused);
|
|
499
|
+
if (cache && key && retained && retained.expiresAt > Date.now()) {
|
|
500
|
+
while (cache.size >= 32 && !cache.has(key))
|
|
501
|
+
cache.delete(cache.keys().next().value);
|
|
502
|
+
cache.set(key, retained);
|
|
503
|
+
}
|
|
315
504
|
return this.sanitize({ schemaVersion: 1, action: request.action, status: 'success', summary, data });
|
|
316
505
|
}
|
|
317
506
|
catch (error) {
|
|
318
507
|
const failure = this.options.signal?.aborted ? new models_1.ToolingError('CANCELLED', 'The MCP action was cancelled.')
|
|
319
508
|
: error instanceof models_1.ToolingError ? error : new models_1.ToolingError('INTERNAL_ERROR', 'The MCP action could not complete.');
|
|
509
|
+
if (cache && key && failure.code !== 'MCP_TOOL_ERROR')
|
|
510
|
+
cache.delete(key);
|
|
320
511
|
return this.sanitize({ schemaVersion: 1, action: request.action, status: failure.code === 'CANCELLED' ? 'cancelled' : 'failure',
|
|
321
|
-
summary: failure.message, error: { code: failure.code, message: failure.message }, ...(resultData === undefined ? {} : { data: resultData }),
|
|
512
|
+
summary: failure.message, error: { code: failure.code, message: failure.message }, ...(resultData === undefined || ['AUTH_REJECTED', 'NOT_AUTHENTICATED'].includes(failure.code) ? {} : { data: resultData }),
|
|
322
513
|
...(callStarted ? { effects: { state: 'partial-or-uncertain', message: 'The tool may have changed remote state. No automatic retry was attempted; verify the outcome before repeating it.' } } : {}) });
|
|
323
514
|
}
|
|
324
515
|
finally {
|
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.McpTransport = void 0;
|
|
3
|
+
exports.McpTransport = exports.describeSeconds = void 0;
|
|
4
|
+
exports.describeMcpMessage = describeMcpMessage;
|
|
5
|
+
exports.failureReason = failureReason;
|
|
4
6
|
exports.readMcpResponse = readMcpResponse;
|
|
5
7
|
exports.sendProjectMcp = sendProjectMcp;
|
|
6
8
|
const models_1 = require("./models.js");
|
|
9
|
+
const projectTools_1 = require("./projectTools.js");
|
|
10
|
+
const safeText = (value, max) => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').trim().slice(0, max);
|
|
11
|
+
/** `initialize`, `tools/list`, `tools/call crm_contacts_list`: what a message asked the server for. */
|
|
12
|
+
function describeMcpMessage(message) {
|
|
13
|
+
const method = typeof message.method === 'string' ? message.method : 'request';
|
|
14
|
+
const name = method === 'tools/call' && message.params && typeof message.params === 'object' ? message.params.name : undefined;
|
|
15
|
+
return typeof name === 'string' && name ? `${method} ${safeText(name, 120)}` : method;
|
|
16
|
+
}
|
|
17
|
+
/** "0.3", "1", "120": a budget in seconds, as a person reads it. */
|
|
18
|
+
const describeSeconds = (ms) => (ms / 1000).toFixed(ms % 1000 ? 1 : 0);
|
|
19
|
+
exports.describeSeconds = describeSeconds;
|
|
20
|
+
/** Why a request failed before any response arrived: the system error code (ECONNREFUSED, ENOTFOUND,
|
|
21
|
+
* ECONNRESET, a certificate code) when there is one. Free-text error messages never reach a host:
|
|
22
|
+
* they can carry addresses and credentials. */
|
|
23
|
+
function failureReason(error) {
|
|
24
|
+
const code = error?.cause?.code ?? error?.code;
|
|
25
|
+
return typeof code === 'string' && /^[A-Z][A-Z0-9_]{1,40}$/.test(code) ? code : '';
|
|
26
|
+
}
|
|
7
27
|
/** The platform's stateless Streamable HTTP profile; no legacy SSE endpoint. */
|
|
8
28
|
class McpTransport {
|
|
9
29
|
send;
|
|
30
|
+
initialized;
|
|
10
31
|
sequence = 0;
|
|
11
|
-
initialized = false
|
|
12
|
-
constructor(send) {
|
|
32
|
+
constructor(send, initialized = false) {
|
|
13
33
|
this.send = send;
|
|
34
|
+
this.initialized = initialized;
|
|
14
35
|
}
|
|
15
36
|
async initialize() {
|
|
16
37
|
const result = await this.request('initialize', {
|
|
@@ -23,19 +44,23 @@ class McpTransport {
|
|
|
23
44
|
await this.send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
24
45
|
this.initialized = true;
|
|
25
46
|
}
|
|
26
|
-
async request(method, params) {
|
|
47
|
+
async request(method, params, options) {
|
|
27
48
|
if (method !== 'initialize' && !this.initialized)
|
|
28
49
|
throw new models_1.ToolingError('API_ERROR', 'The MCP connection has not initialized.');
|
|
29
50
|
const id = ++this.sequence;
|
|
30
|
-
const
|
|
51
|
+
const message = { jsonrpc: '2.0', id, method, params };
|
|
52
|
+
const response = await this.send(message, options);
|
|
31
53
|
if (!response || response.jsonrpc !== '2.0' || response.id !== id) {
|
|
32
|
-
throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint returned an invalid or mismatched response.');
|
|
54
|
+
throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint returned an invalid or mismatched response to ' + describeMcpMessage(message) + '.');
|
|
55
|
+
}
|
|
56
|
+
if (response.error) {
|
|
57
|
+
// The server's own words, bounded; the dispatcher redacts credentials before any host sees them.
|
|
58
|
+
const detail = safeText(response.error.message, 300);
|
|
59
|
+
throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint rejected ' + describeMcpMessage(message)
|
|
60
|
+
+ (Number.isInteger(response.error.code) ? ' (JSON-RPC ' + response.error.code + ')' : '') + (detail ? ': ' + detail : '.'));
|
|
33
61
|
}
|
|
34
|
-
if (response.error)
|
|
35
|
-
throw new models_1.ToolingError('API_ERROR', 'The MCP endpoint rejected ' + method
|
|
36
|
-
+ (Number.isInteger(response.error.code) ? ' (JSON-RPC ' + response.error.code + ')' : '') + '.');
|
|
37
62
|
if (!Object.hasOwn(response, 'result'))
|
|
38
|
-
throw new models_1.ToolingError('API_ERROR', 'The MCP response has no result.');
|
|
63
|
+
throw new models_1.ToolingError('API_ERROR', 'The MCP response to ' + describeMcpMessage(message) + ' has no result.');
|
|
39
64
|
return response.result;
|
|
40
65
|
}
|
|
41
66
|
async tools() {
|
|
@@ -46,11 +71,24 @@ class McpTransport {
|
|
|
46
71
|
if (!Array.isArray(page?.tools))
|
|
47
72
|
throw new models_1.ToolingError('API_ERROR', 'The MCP tool catalog is invalid.');
|
|
48
73
|
for (const tool of page.tools) {
|
|
49
|
-
if (!tool || typeof tool.name !== 'string' || !tool.name || !tool.inputSchema || typeof tool.inputSchema !== 'object' || names.has(tool.name)) {
|
|
74
|
+
if (!tool || typeof tool.name !== 'string' || !tool.name || !tool.inputSchema || typeof tool.inputSchema !== 'object' || Array.isArray(tool.inputSchema) || names.has(tool.name)) {
|
|
50
75
|
throw new models_1.ToolingError('API_ERROR', 'The MCP tool catalog contains an invalid or duplicate tool.');
|
|
51
76
|
}
|
|
77
|
+
const normalized = { ...tool };
|
|
78
|
+
if (typeof normalized.description !== 'string')
|
|
79
|
+
delete normalized.description;
|
|
80
|
+
for (const field of ['annotations', '_meta'])
|
|
81
|
+
if (!normalized[field] || typeof normalized[field] !== 'object' || Array.isArray(normalized[field]))
|
|
82
|
+
delete normalized[field];
|
|
83
|
+
if (normalized.annotations) {
|
|
84
|
+
normalized.annotations = { ...normalized.annotations };
|
|
85
|
+
for (const flag of ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']) {
|
|
86
|
+
if (typeof normalized.annotations[flag] !== 'boolean')
|
|
87
|
+
delete normalized.annotations[flag];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
52
90
|
names.add(tool.name);
|
|
53
|
-
tools.push(
|
|
91
|
+
tools.push(normalized);
|
|
54
92
|
}
|
|
55
93
|
cursor = page.nextCursor;
|
|
56
94
|
if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || cursors.has(cursor))) {
|
|
@@ -65,29 +103,31 @@ class McpTransport {
|
|
|
65
103
|
}
|
|
66
104
|
}
|
|
67
105
|
exports.McpTransport = McpTransport;
|
|
68
|
-
/** Read bounded JSON or POST SSE; never reconnect or replay a tool invocation.
|
|
69
|
-
|
|
106
|
+
/** Read bounded JSON or POST SSE; never reconnect or replay a tool invocation. `target` names the
|
|
107
|
+
* endpoint in errors ("project MCP https://crm.example"), so a person or a model sees which server
|
|
108
|
+
* answered which request with which status. */
|
|
109
|
+
async function readMcpResponse(response, message, target = 'MCP endpoint') {
|
|
70
110
|
if (response.headers.has('mcp-session-id')) {
|
|
71
111
|
await response.body?.cancel();
|
|
72
112
|
throw new models_1.ToolingError('UNSUPPORTED_ACTION', 'Stateful MCP sessions are outside the platform profile.');
|
|
73
113
|
}
|
|
74
114
|
if (!response.ok) {
|
|
75
115
|
await response.body?.cancel();
|
|
76
|
-
throw new models_1.ToolingError(response.status === 401 || response.status === 403 ? 'AUTH_REJECTED' : 'API_ERROR', 'The
|
|
116
|
+
throw new models_1.ToolingError(response.status === 401 || response.status === 403 ? 'AUTH_REJECTED' : 'API_ERROR', 'The ' + target + ' returned HTTP ' + response.status + ' for ' + describeMcpMessage(message) + '.');
|
|
77
117
|
}
|
|
78
118
|
if (!Object.hasOwn(message, 'id')) {
|
|
79
119
|
await response.body?.cancel();
|
|
80
120
|
if (response.status !== 202 && response.status !== 204)
|
|
81
|
-
throw new models_1.ToolingError('API_ERROR', 'The
|
|
121
|
+
throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' did not acknowledge ' + describeMcpMessage(message) + ' (HTTP ' + response.status + ').');
|
|
82
122
|
return undefined;
|
|
83
123
|
}
|
|
84
124
|
const kind = response.headers.get('content-type')?.split(';')[0].trim();
|
|
85
125
|
if (kind !== 'application/json' && kind !== 'text/event-stream') {
|
|
86
126
|
await response.body?.cancel();
|
|
87
|
-
throw new models_1.ToolingError('API_ERROR', 'The
|
|
127
|
+
throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' returned an unsupported content type for ' + describeMcpMessage(message) + '.');
|
|
88
128
|
}
|
|
89
129
|
if (!response.body)
|
|
90
|
-
throw new models_1.ToolingError('API_ERROR', 'The
|
|
130
|
+
throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' returned an empty response to ' + describeMcpMessage(message) + '.');
|
|
91
131
|
const reader = response.body.getReader(), decoder = new TextDecoder();
|
|
92
132
|
let size = 0, text = '';
|
|
93
133
|
const parse = (raw) => {
|
|
@@ -95,7 +135,7 @@ async function readMcpResponse(response, message) {
|
|
|
95
135
|
return JSON.parse(raw);
|
|
96
136
|
}
|
|
97
137
|
catch {
|
|
98
|
-
throw new models_1.ToolingError('API_ERROR', 'The
|
|
138
|
+
throw new models_1.ToolingError('API_ERROR', 'The ' + target + ' returned invalid JSON for ' + describeMcpMessage(message) + '.');
|
|
99
139
|
}
|
|
100
140
|
};
|
|
101
141
|
try {
|
|
@@ -130,32 +170,48 @@ async function readMcpResponse(response, message) {
|
|
|
130
170
|
text += decoder.decode();
|
|
131
171
|
if (kind === 'application/json')
|
|
132
172
|
return parse(text);
|
|
133
|
-
throw new models_1.ToolingError('API_ERROR', 'The MCP stream ended before the response arrived. The call was not retried.');
|
|
173
|
+
throw new models_1.ToolingError('API_ERROR', 'The MCP stream from the ' + target + ' ended before the response to ' + describeMcpMessage(message) + ' arrived. The call was not retried.');
|
|
134
174
|
}
|
|
135
175
|
finally {
|
|
136
176
|
await reader.cancel().catch(() => { });
|
|
137
177
|
reader.releaseLock();
|
|
138
178
|
}
|
|
139
179
|
}
|
|
180
|
+
/** One request to a project's MCP endpoint with the release credential. The budget is the project
|
|
181
|
+
* request timeout (default 120 s: a scaled-to-zero release has taken over a minute to answer
|
|
182
|
+
* `initialize`), or the caller's own for a tool that advertises a longer run. A timeout and a
|
|
183
|
+
* connection failure keep the same code, `BACKEND_UNREACHABLE`, and say which they were. */
|
|
140
184
|
async function sendProjectMcp(url, token, message, options) {
|
|
141
185
|
options.signal?.throwIfAborted();
|
|
142
186
|
const controller = new AbortController(), abort = () => controller.abort();
|
|
143
187
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
144
|
-
const
|
|
188
|
+
const budget = options.timeoutMs ?? projectTools_1.DEFAULT_PROJECT_REQUEST_TIMEOUT_MS;
|
|
189
|
+
let timedOut = false;
|
|
190
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, budget);
|
|
191
|
+
const origin = (() => { try {
|
|
192
|
+
return new URL(url).origin;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return url;
|
|
196
|
+
} })();
|
|
145
197
|
try {
|
|
146
198
|
return await readMcpResponse(await (options.fetch ?? fetch)(url, {
|
|
147
199
|
method: 'POST', redirect: 'error', signal: controller.signal,
|
|
200
|
+
...(['initialize', 'notifications/initialized', 'tools/list'].includes(String(message.method)) ? { cache: 'force-cache' } : {}),
|
|
148
201
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream',
|
|
149
202
|
Authorization: 'Bearer ' + token, 'MCP-Protocol-Version': '2025-11-25' },
|
|
150
203
|
body: JSON.stringify(message),
|
|
151
|
-
}), message);
|
|
204
|
+
}), message, 'project MCP ' + origin);
|
|
152
205
|
}
|
|
153
206
|
catch (error) {
|
|
154
207
|
if (options.signal?.aborted)
|
|
155
208
|
throw new models_1.ToolingError('CANCELLED', 'The MCP request was cancelled. Cancellation does not undo a remote operation.');
|
|
156
209
|
if (error instanceof models_1.ToolingError)
|
|
157
210
|
throw error;
|
|
158
|
-
|
|
211
|
+
if (timedOut)
|
|
212
|
+
throw new models_1.ToolingError('BACKEND_UNREACHABLE', 'The project MCP ' + describeMcpMessage(message) + ' request to ' + origin + ' timed out after ' + (0, exports.describeSeconds)(budget) + ' s. It was not retried.');
|
|
213
|
+
const reason = failureReason(error);
|
|
214
|
+
throw new models_1.ToolingError('BACKEND_UNREACHABLE', 'The project MCP ' + describeMcpMessage(message) + ' request to ' + origin + ' failed before a response arrived' + (reason ? ' (' + reason + ')' : '') + '. It was not retried.');
|
|
159
215
|
}
|
|
160
216
|
finally {
|
|
161
217
|
clearTimeout(timer);
|
|
@@ -139,6 +139,8 @@ async function namedDirectory(parent, name, uid) {
|
|
|
139
139
|
/** Stable, independent branch checkouts. No reset, stash, force checkout, deletion or automatic pull. */
|
|
140
140
|
async function setupOrganizationProject(input, options) {
|
|
141
141
|
const { session, signal, state } = options;
|
|
142
|
+
if (input.instructionsFile !== undefined && !['AGENTS.md', 'CLAUDE.md'].includes(input.instructionsFile))
|
|
143
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Choose AGENTS.md or CLAUDE.md instructions.');
|
|
142
144
|
const backend = (0, session_1.requireBackend)(input.backend), environmentUid = (0, organizationProjects_1.projectUid)(input.environmentUid), uid = (0, organizationProjects_1.projectUid)(input.projectUid);
|
|
143
145
|
const env = (0, projects_1.projectEnvironment)({ backend }, options.env);
|
|
144
146
|
// Apply the same environment to nested Git helpers, including context and .env setup.
|
|
@@ -283,13 +285,16 @@ async function setupOrganizationProject(input, options) {
|
|
|
283
285
|
if (await run(row.path, ['symbolic-ref', '--short', 'HEAD']) !== row.branch)
|
|
284
286
|
throw new models_1.ToolingError('INVALID_GIT_CONTEXT', 'The checkout did not select the project branch.');
|
|
285
287
|
}
|
|
286
|
-
const
|
|
288
|
+
const instructionsFile = input.instructionsFile ?? 'CLAUDE.md';
|
|
289
|
+
if (!['AGENTS.md', 'CLAUDE.md'].includes(instructionsFile))
|
|
290
|
+
throw new models_1.ToolingError('INVALID_REQUEST', 'Choose AGENTS.md or CLAUDE.md instructions.');
|
|
291
|
+
const instructions = path.join(root, instructionsFile);
|
|
287
292
|
if (!await exists(instructions)) {
|
|
288
293
|
const legacyInstructions = path.join(container, 'CLAUDE.md');
|
|
289
|
-
const content = prior?.schemaVersion === 1 && await exists(legacyInstructions)
|
|
294
|
+
const content = instructionsFile === 'CLAUDE.md' && prior?.schemaVersion === 1 && await exists(legacyInstructions)
|
|
290
295
|
? await fs.readFile(legacyInstructions, 'utf8')
|
|
291
296
|
: '# MainSequence workspace\n\nThe project repositories and their assigned branches are listed in `.mainsequence-project.json`.\nThe MainSequence plugin adds each repository to this Claude session with `/add-dir`.\nWork inside the appropriate repository directory; each checkout has its own Git history and instructions.\n';
|
|
292
|
-
await fs.writeFile(instructions, content, { flag: 'wx' });
|
|
297
|
+
await fs.writeFile(instructions, instructionsFile === 'AGENTS.md' ? content.replace('The MainSequence plugin adds each repository to this Claude session with `/add-dir`.', 'Open the repository paths listed in the manifest in your editor. Grant access to each checkout before working in it.') : content, { flag: 'wx' });
|
|
293
298
|
}
|
|
294
299
|
await atomicJson(manifestPath, { ...manifest, status: 'ready' });
|
|
295
300
|
await atomicJson(path.join(root, '.mainsequence-project.json'), { ...manifest, status: 'ready' });
|