fraim-framework 2.0.186 → 2.0.187
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/dist/src/ai-hub/conversation-store.js +97 -6
- package/dist/src/ai-hub/server.js +80 -10
- package/dist/src/cli/commands/add-ide.js +3 -2
- package/dist/src/cli/commands/add-provider.js +7 -1
- package/dist/src/cli/commands/sync.js +2 -1
- package/dist/src/cli/mcp/ide-formats.js +36 -1
- package/dist/src/cli/setup/auto-mcp-setup.js +1 -1
- package/dist/src/cli/setup/ide-detector.js +29 -1
- package/dist/src/cli/setup/ide-global-integration.js +1 -1
- package/dist/src/cli/setup/mcp-config-generator.js +12 -1
- package/dist/src/cli/utils/agent-adapters.js +8 -2
- package/dist/src/first-run/types.js +1 -1
- package/package.json +1 -1
- package/public/ai-hub/script.js +242 -15
- package/public/ai-hub/styles.css +19 -0
|
@@ -3,10 +3,27 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.AiHubConversationStore = void 0;
|
|
6
|
+
exports.AiHubConversationStore = exports.COMPANY_SCOPE_KEY = exports.MANAGER_SCOPE_KEY = void 0;
|
|
7
|
+
exports.conversationScopeKey = conversationScopeKey;
|
|
7
8
|
const fs_1 = __importDefault(require("fs"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
9
10
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
11
|
+
// Issue #708: reserved, project-independent bucket keys for non-project scopes.
|
|
12
|
+
// They are NOT filesystem paths and must never be passed through path.resolve.
|
|
13
|
+
exports.MANAGER_SCOPE_KEY = '@manager';
|
|
14
|
+
exports.COMPANY_SCOPE_KEY = '@company';
|
|
15
|
+
/**
|
|
16
|
+
* Issue #708: resolve the conversation store bucket key for a given scope.
|
|
17
|
+
* - 'manager'/'company' → a stable sentinel key (project-independent home).
|
|
18
|
+
* - 'project' (or undefined) → the resolved project path (existing behavior).
|
|
19
|
+
*/
|
|
20
|
+
function conversationScopeKey(scope, projectPath) {
|
|
21
|
+
if (scope === 'manager')
|
|
22
|
+
return exports.MANAGER_SCOPE_KEY;
|
|
23
|
+
if (scope === 'company')
|
|
24
|
+
return exports.COMPANY_SCOPE_KEY;
|
|
25
|
+
return normalizeConversationKey(projectPath);
|
|
26
|
+
}
|
|
10
27
|
const emptyProjectState = () => ({
|
|
11
28
|
activeId: null,
|
|
12
29
|
conversations: [],
|
|
@@ -23,8 +40,58 @@ function timestampValue(value) {
|
|
|
23
40
|
}
|
|
24
41
|
return 0;
|
|
25
42
|
}
|
|
43
|
+
// Issue #708: sentinel scope keys (e.g. '@manager') are stable bucket keys, not paths —
|
|
44
|
+
// pass them through untouched; resolve everything else as a real project path.
|
|
45
|
+
function normalizeConversationKey(key) {
|
|
46
|
+
if (typeof key === 'string' && (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)) {
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
return path_1.default.resolve(key || process.cwd());
|
|
50
|
+
}
|
|
51
|
+
// Retained name for project-path stamping; now sentinel-aware so manager/company
|
|
52
|
+
// records keep their scope key rather than being resolved into cwd/@manager.
|
|
26
53
|
function normalizeProjectPath(projectPath) {
|
|
27
|
-
return
|
|
54
|
+
return normalizeConversationKey(projectPath);
|
|
55
|
+
}
|
|
56
|
+
// Issue #708: one-time, idempotent migration. Legacy manager/company-invoked runs were
|
|
57
|
+
// stored inside project buckets and distinguished only by the client-side `invokedArea`
|
|
58
|
+
// field. Fold them into the reserved scope buckets (stamping `scope`) so they have a
|
|
59
|
+
// stable, project-independent home. Runs already in a scope bucket are left untouched.
|
|
60
|
+
function migrateScopeBuckets(store) {
|
|
61
|
+
const projects = {};
|
|
62
|
+
const ensure = (key) => {
|
|
63
|
+
if (!projects[key])
|
|
64
|
+
projects[key] = { activeId: null, conversations: [] };
|
|
65
|
+
return projects[key];
|
|
66
|
+
};
|
|
67
|
+
// Seed existing scope buckets first so their activeId is preserved.
|
|
68
|
+
for (const [key, state] of Object.entries(store.projects || {})) {
|
|
69
|
+
if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY) {
|
|
70
|
+
ensure(key).conversations.push(...(state.conversations || []));
|
|
71
|
+
projects[key].activeId = state.activeId ?? null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const [key, state] of Object.entries(store.projects || {})) {
|
|
75
|
+
if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)
|
|
76
|
+
continue;
|
|
77
|
+
const stay = [];
|
|
78
|
+
for (const conv of (state.conversations || [])) {
|
|
79
|
+
const scope = conv.scope
|
|
80
|
+
?? conv.invokedArea;
|
|
81
|
+
if (scope === 'manager' || scope === 'company') {
|
|
82
|
+
const bucketKey = scope === 'manager' ? exports.MANAGER_SCOPE_KEY : exports.COMPANY_SCOPE_KEY;
|
|
83
|
+
const bucket = ensure(bucketKey);
|
|
84
|
+
if (!bucket.conversations.some((c) => c && c.id === conv.id)) {
|
|
85
|
+
bucket.conversations.push({ ...conv, scope: scope });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
stay.push(conv);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
projects[key] = { activeId: state.activeId ?? null, conversations: stay };
|
|
93
|
+
}
|
|
94
|
+
return { version: 1, projects };
|
|
28
95
|
}
|
|
29
96
|
function normalizeConversation(projectPath, raw) {
|
|
30
97
|
if (!raw || typeof raw !== 'object')
|
|
@@ -77,6 +144,32 @@ class AiHubConversationStore {
|
|
|
77
144
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
78
145
|
return normalizeProjectState(normalizedProjectPath, state.projects[normalizedProjectPath]);
|
|
79
146
|
}
|
|
147
|
+
// Issue #719: delete a project's KEY from the store entirely. Writing an empty
|
|
148
|
+
// conversation list is not enough — knownProjects re-derives a project from
|
|
149
|
+
// every stored key (listProjectPaths), so removal must drop the key itself.
|
|
150
|
+
// Sentinel scope buckets (@manager/@company) are not projects and never removable.
|
|
151
|
+
removeProject(projectPath) {
|
|
152
|
+
if (projectPath === exports.MANAGER_SCOPE_KEY || projectPath === exports.COMPANY_SCOPE_KEY)
|
|
153
|
+
return false;
|
|
154
|
+
const canonicalKey = (value) => {
|
|
155
|
+
const resolved = path_1.default.resolve(value);
|
|
156
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
157
|
+
};
|
|
158
|
+
const target = canonicalKey(projectPath);
|
|
159
|
+
const state = this.readStore();
|
|
160
|
+
let removed = false;
|
|
161
|
+
for (const key of Object.keys(state.projects)) {
|
|
162
|
+
if (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)
|
|
163
|
+
continue;
|
|
164
|
+
if (canonicalKey(key) === target) {
|
|
165
|
+
delete state.projects[key];
|
|
166
|
+
removed = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (removed)
|
|
170
|
+
this.writeStore(state);
|
|
171
|
+
return removed;
|
|
172
|
+
}
|
|
80
173
|
replaceProject(projectPath, next) {
|
|
81
174
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
82
175
|
const normalized = normalizeProjectState(normalizedProjectPath, next);
|
|
@@ -149,10 +242,8 @@ class AiHubConversationStore {
|
|
|
149
242
|
if (raw.version !== 1 || !raw.projects || typeof raw.projects !== 'object') {
|
|
150
243
|
return { version: 1, projects: {} };
|
|
151
244
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
projects: raw.projects,
|
|
155
|
-
};
|
|
245
|
+
// Issue #708: fold legacy manager/company-invoked runs into scope buckets on read.
|
|
246
|
+
return migrateScopeBuckets({ version: 1, projects: raw.projects });
|
|
156
247
|
}
|
|
157
248
|
catch {
|
|
158
249
|
return { version: 1, projects: {} };
|
|
@@ -1470,6 +1470,11 @@ class AiHubServer {
|
|
|
1470
1470
|
compareRunId: run.compareRunId || null,
|
|
1471
1471
|
// Issue #578: preserve trigger source so the UI can render the chip.
|
|
1472
1472
|
sourceTrigger: run.sourceTrigger,
|
|
1473
|
+
// Issue #708: carry the invocation scope so the record lands in (and is keyed to)
|
|
1474
|
+
// the right bucket. Falls back to the legacy client `invokedArea` when present.
|
|
1475
|
+
scope: run.scope
|
|
1476
|
+
?? run.invokedArea
|
|
1477
|
+
?? 'project',
|
|
1473
1478
|
run: {
|
|
1474
1479
|
stages,
|
|
1475
1480
|
currentPhase: run.currentPhase || null,
|
|
@@ -1481,7 +1486,11 @@ class AiHubServer {
|
|
|
1481
1486
|
}
|
|
1482
1487
|
persistRunConversation(run, activeId) {
|
|
1483
1488
|
try {
|
|
1484
|
-
|
|
1489
|
+
// Issue #708: route the record to its scope bucket (manager/company runs get a
|
|
1490
|
+
// project-independent home); project runs continue to key by project path.
|
|
1491
|
+
const record = this.conversationRecordFromRun(run);
|
|
1492
|
+
const key = (0, conversation_store_1.conversationScopeKey)(record.scope, run.projectPath);
|
|
1493
|
+
this.conversationStore.upsertConversation(key, record, activeId);
|
|
1485
1494
|
}
|
|
1486
1495
|
catch (error) {
|
|
1487
1496
|
console.warn('[ai-hub] conversation store write failed:', error instanceof Error ? error.message : error);
|
|
@@ -2061,6 +2070,19 @@ class AiHubServer {
|
|
|
2061
2070
|
}
|
|
2062
2071
|
// Read API key from header — query-param API keys are prohibited (§3.14)
|
|
2063
2072
|
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
2073
|
+
// #719: a bare reload (no projectPath query) must land on the last-recorded
|
|
2074
|
+
// workspace project, not the launch folder. The bootstrap saves its resolved
|
|
2075
|
+
// current path back into the projects list, so defaulting to the launch
|
|
2076
|
+
// folder would resurrect a removed launch-folder project on every reload
|
|
2077
|
+
// (tfSwitchProjectFolder already documents the recorded folder as "the
|
|
2078
|
+
// default on next load"). The launch folder stays the fallback when nothing
|
|
2079
|
+
// is recorded or the recorded folder no longer exists.
|
|
2080
|
+
if (!projectPath) {
|
|
2081
|
+
const recorded = this.preferencesStore.load(this.projectPath).projectPath;
|
|
2082
|
+
if (recorded && path_1.default.resolve(recorded) !== path_1.default.resolve(this.projectPath) && fs_1.default.existsSync(recorded)) {
|
|
2083
|
+
projectPath = recorded;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2064
2086
|
res.json(await this.bootstrapResponse(projectPath || this.projectPath, apiKey));
|
|
2065
2087
|
});
|
|
2066
2088
|
// Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
|
|
@@ -2146,25 +2168,32 @@ class AiHubServer {
|
|
|
2146
2168
|
await this.remoteGateway.removeManagerTeam(apiKey, personaKey);
|
|
2147
2169
|
return res.status(204).end();
|
|
2148
2170
|
});
|
|
2171
|
+
// Issue #708: manager/company scopes resolve to a project-independent sentinel bucket
|
|
2172
|
+
// key (which must bypass path/dir resolution); everything else keys by project path.
|
|
2173
|
+
const scopeParam = (raw) => (raw === 'manager' || raw === 'company') ? raw : undefined;
|
|
2149
2174
|
this.app.get('/api/ai-hub/conversations', (req, res) => {
|
|
2150
|
-
const
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2175
|
+
const scope = scopeParam(req.query.scope);
|
|
2176
|
+
const key = scope
|
|
2177
|
+
? (0, conversation_store_1.conversationScopeKey)(scope, '')
|
|
2178
|
+
: (typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2179
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
2180
|
+
: this.projectPath);
|
|
2181
|
+
const loaded = this.conversationStore.loadProject(key);
|
|
2182
|
+
return res.json({ projectPath: key, scope: scope ?? 'project', ...loaded, source: 'disk' });
|
|
2155
2183
|
});
|
|
2156
2184
|
this.app.put('/api/ai-hub/conversations', (req, res) => {
|
|
2157
2185
|
try {
|
|
2158
2186
|
const body = (req.body ?? {});
|
|
2159
|
-
const
|
|
2187
|
+
const scope = scopeParam(body.scope);
|
|
2188
|
+
const key = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
2160
2189
|
if (!Array.isArray(body.conversations)) {
|
|
2161
2190
|
return res.status(400).json({ error: 'conversations array required' });
|
|
2162
2191
|
}
|
|
2163
|
-
const saved = this.conversationStore.replaceProject(
|
|
2192
|
+
const saved = this.conversationStore.replaceProject(key, {
|
|
2164
2193
|
activeId: body.activeId ?? null,
|
|
2165
2194
|
conversations: body.conversations,
|
|
2166
2195
|
});
|
|
2167
|
-
return res.json({ projectPath, ...saved, source: 'disk' });
|
|
2196
|
+
return res.json({ projectPath: key, scope: scope ?? 'project', ...saved, source: 'disk' });
|
|
2168
2197
|
}
|
|
2169
2198
|
catch (error) {
|
|
2170
2199
|
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversations.' });
|
|
@@ -2173,7 +2202,8 @@ class AiHubServer {
|
|
|
2173
2202
|
this.app.patch('/api/ai-hub/conversations/:conversationId', (req, res) => {
|
|
2174
2203
|
try {
|
|
2175
2204
|
const body = (req.body ?? {});
|
|
2176
|
-
const
|
|
2205
|
+
const scope = scopeParam(body.scope);
|
|
2206
|
+
const projectPath = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
2177
2207
|
const saved = this.conversationStore.patchConversation(projectPath, req.params.conversationId, body);
|
|
2178
2208
|
if (body.activeId !== undefined) {
|
|
2179
2209
|
const withActive = this.conversationStore.replaceProject(projectPath, { ...saved, activeId: body.activeId });
|
|
@@ -2205,6 +2235,46 @@ class AiHubServer {
|
|
|
2205
2235
|
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
|
|
2206
2236
|
}
|
|
2207
2237
|
});
|
|
2238
|
+
// Issue #719: DELETE /api/ai-hub/projects/:id — remove a project from the Hub.
|
|
2239
|
+
// PUT is a merge by design and cannot express removal, so removal gets its own
|
|
2240
|
+
// route (same convention as /schedules/:id, /webhooks/:id, /hosts/:id). Deletes
|
|
2241
|
+
// every server-side derivation layer — preferences entry, conversation-store
|
|
2242
|
+
// KEY, project-scoped deployments — so the project cannot resurrect (R3/R6/R9).
|
|
2243
|
+
// Never touches anything under the project's folderPath (R8).
|
|
2244
|
+
this.app.delete('/api/ai-hub/projects/:id', (req, res) => {
|
|
2245
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2246
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
2247
|
+
: this.projectPath;
|
|
2248
|
+
const known = this.knownProjects(projectPath);
|
|
2249
|
+
const entry = known.find((project) => project.id === req.params.id);
|
|
2250
|
+
if (!entry)
|
|
2251
|
+
return res.status(404).json({ error: 'Project not found.' });
|
|
2252
|
+
// The current projectPath is unconditionally re-injected on every load, so
|
|
2253
|
+
// deleting it would resurrect on the next request — reject as a sequencing
|
|
2254
|
+
// error; the client switches workspaces before deleting (R5/D4).
|
|
2255
|
+
if (sameDirectoryPath(entry.folderPath, projectPath)) {
|
|
2256
|
+
return res.status(409).json({ error: 'Cannot remove the current project. Switch to another project first.' });
|
|
2257
|
+
}
|
|
2258
|
+
// A live schedule/webhook would fire later, write a conversation under the
|
|
2259
|
+
// removed folderPath, and resurrect the project in the background (R6).
|
|
2260
|
+
for (const deployment of this.deploymentStore.load()) {
|
|
2261
|
+
if (!deploymentBelongsToProject(deployment, entry.folderPath, this.projectPath))
|
|
2262
|
+
continue;
|
|
2263
|
+
const task = this.cronHandles.get(deployment.id);
|
|
2264
|
+
if (task) {
|
|
2265
|
+
task.stop();
|
|
2266
|
+
this.cronHandles.delete(deployment.id);
|
|
2267
|
+
}
|
|
2268
|
+
this.deploymentStore.delete(deployment.id);
|
|
2269
|
+
}
|
|
2270
|
+
// Delete the conversation-store KEY — an empty list would still re-derive
|
|
2271
|
+
// the project via listProjectPaths (R9).
|
|
2272
|
+
this.conversationStore.removeProject(entry.folderPath);
|
|
2273
|
+
// Persist the filtered list directly: saveKnownProjects unions with the
|
|
2274
|
+
// derived list by design and cannot express removal.
|
|
2275
|
+
const projects = this.preferencesStore.saveProjects(projectPath, known.filter((project) => project.id !== entry.id));
|
|
2276
|
+
return res.json({ ok: true, projects });
|
|
2277
|
+
});
|
|
2208
2278
|
this.app.post('/api/ai-hub/api-key', (req, res) => {
|
|
2209
2279
|
const { apiKey } = req.body;
|
|
2210
2280
|
if (!apiKey || typeof apiKey !== 'string')
|
|
@@ -281,6 +281,7 @@ const listSupportedIDEs = () => {
|
|
|
281
281
|
console.log(chalk_1.default.yellow(' Example: fraim add-ide --ide claude-code'));
|
|
282
282
|
console.log(chalk_1.default.yellow(' Anthropic aliases: claude, claude-code, claude-desktop, claude-cowork'));
|
|
283
283
|
console.log(chalk_1.default.yellow(' Gemini aliases: gemini, gemini-cli, gemini cli'));
|
|
284
|
+
console.log(chalk_1.default.yellow(' GitHub Copilot CLI aliases: copilot, copilot-cli, github copilot cli'));
|
|
284
285
|
};
|
|
285
286
|
const promptForIDESelection = async (availableIDEs, tokens) => {
|
|
286
287
|
console.log(chalk_1.default.green(`✅ Found ${availableIDEs.length} IDEs that can be configured:\n`));
|
|
@@ -385,7 +386,7 @@ const runAddIDE = async (options) => {
|
|
|
385
386
|
const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
|
|
386
387
|
if (detectedIDEs.length === 0) {
|
|
387
388
|
console.log(chalk_1.default.yellow('⚠️ No supported IDEs detected on your system.'));
|
|
388
|
-
console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, Kiro, Cursor, VSCode, Codex, Grok, Windsurf'));
|
|
389
|
+
console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, GitHub Copilot CLI, Kiro, Cursor, VSCode, Codex, Grok, Windsurf'));
|
|
389
390
|
console.log(chalk_1.default.blue('\n💡 Install an IDE and run this command again.'));
|
|
390
391
|
return;
|
|
391
392
|
}
|
|
@@ -455,7 +456,7 @@ const runAddIDE = async (options) => {
|
|
|
455
456
|
exports.runAddIDE = runAddIDE;
|
|
456
457
|
exports.addIDECommand = new commander_1.Command('add-ide')
|
|
457
458
|
.description('Add FRAIM configuration to additional IDEs')
|
|
458
|
-
.option('--ide <name>', 'Configure specific IDE (claude, claude-code, claude-desktop, claude-cowork, antigravity, gemini, gemini-cli, kiro, cursor, vscode, codex, grok, windsurf)')
|
|
459
|
+
.option('--ide <name>', 'Configure specific IDE (claude, claude-code, claude-desktop, claude-cowork, antigravity, gemini, gemini-cli, copilot, copilot-cli, kiro, cursor, vscode, codex, grok, windsurf)')
|
|
459
460
|
.option('--all', 'Configure all detected IDEs')
|
|
460
461
|
.option('--list', 'List all supported IDEs and their detection status')
|
|
461
462
|
.action(exports.runAddIDE);
|
|
@@ -52,6 +52,10 @@ const forEachInstalledIDE = async (callback) => {
|
|
|
52
52
|
const writeOAuthProviderToIDEConfigs = async (provider, mcpUrl) => {
|
|
53
53
|
const urlOnlyEntry = { type: 'http', url: mcpUrl };
|
|
54
54
|
await forEachInstalledIDE(async (ide, configPath) => {
|
|
55
|
+
if (provider === 'github' && ide.configType === 'copilot-cli') {
|
|
56
|
+
console.log(chalk_1.default.gray(`Skipped ${ide.name} GitHub MCP entry (built into Copilot CLI)`));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
55
59
|
if (ide.configFormat === 'json') {
|
|
56
60
|
let ideConfig = {};
|
|
57
61
|
if (fs_1.default.existsSync(configPath)) {
|
|
@@ -61,7 +65,9 @@ const writeOAuthProviderToIDEConfigs = async (provider, mcpUrl) => {
|
|
|
61
65
|
if (!ideConfig[serversKey]) {
|
|
62
66
|
ideConfig[serversKey] = {};
|
|
63
67
|
}
|
|
64
|
-
ideConfig[serversKey][provider] =
|
|
68
|
+
ideConfig[serversKey][provider] = ide.configType === 'copilot-cli'
|
|
69
|
+
? { ...urlOnlyEntry, tools: ['*'] }
|
|
70
|
+
: urlOnlyEntry;
|
|
65
71
|
fs_1.default.writeFileSync(configPath, JSON.stringify(ideConfig, null, 2));
|
|
66
72
|
console.log(chalk_1.default.green(`✅ Updated ${ide.name} config`));
|
|
67
73
|
}
|
|
@@ -135,7 +135,8 @@ async function cleanupStaleAdapterFiles(projectRoot, allowedConfigTypes) {
|
|
|
135
135
|
const { promisify } = await Promise.resolve().then(() => __importStar(require('util')));
|
|
136
136
|
const execFileAsync = promisify(execFile);
|
|
137
137
|
for (const [relPath, configType] of Object.entries((0, agent_adapters_1.getAdapterConfigTypes)())) {
|
|
138
|
-
|
|
138
|
+
const configTypes = Array.isArray(configType) ? configType : [configType];
|
|
139
|
+
if (configTypes.includes('standard') || configTypes.some((type) => allowedConfigTypes.includes(type))) {
|
|
139
140
|
continue;
|
|
140
141
|
}
|
|
141
142
|
const fullPath = path_1.default.join(projectRoot, relPath);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// IDE Format Adapters - transform logical server structure to IDE-specific formats
|
|
3
3
|
// Uses the centralized registry to determine server types
|
|
4
4
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
-
exports.IDE_FORMATS = exports.GrokFormat = exports.CodexFormat = exports.WindsurfFormat = exports.ClaudeCodeFormat = exports.ClaudeFormat = exports.GeminiCliFormat = exports.VSCodeFormat = exports.KiroFormat = exports.StandardFormat = void 0;
|
|
5
|
+
exports.IDE_FORMATS = exports.GrokFormat = exports.CodexFormat = exports.WindsurfFormat = exports.ClaudeCodeFormat = exports.ClaudeFormat = exports.CopilotCliFormat = exports.GeminiCliFormat = exports.VSCodeFormat = exports.KiroFormat = exports.StandardFormat = void 0;
|
|
6
6
|
exports.getIDEFormat = getIDEFormat;
|
|
7
7
|
const mcp_server_registry_1 = require("./mcp-server-registry");
|
|
8
8
|
const provider_registry_1 = require("../providers/provider-registry");
|
|
@@ -120,6 +120,40 @@ class GeminiCliFormat {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
exports.GeminiCliFormat = GeminiCliFormat;
|
|
123
|
+
class CopilotCliFormat {
|
|
124
|
+
constructor() {
|
|
125
|
+
this.name = 'copilot-cli';
|
|
126
|
+
}
|
|
127
|
+
transform(servers) {
|
|
128
|
+
const mcpServers = {};
|
|
129
|
+
for (const [key, server] of servers) {
|
|
130
|
+
// GitHub MCP is built into Copilot CLI. Avoid a duplicate user-configured
|
|
131
|
+
// github entry and let Copilot handle its native GitHub auth path.
|
|
132
|
+
if (key === 'github') {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (server.url) {
|
|
136
|
+
mcpServers[key] = {
|
|
137
|
+
type: server.type === 'sse' ? 'sse' : 'http',
|
|
138
|
+
url: server.url,
|
|
139
|
+
...(server.headers && { headers: server.headers }),
|
|
140
|
+
tools: ['*']
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
mcpServers[key] = {
|
|
145
|
+
type: 'stdio',
|
|
146
|
+
command: server.command,
|
|
147
|
+
...(server.args && { args: server.args }),
|
|
148
|
+
...(server.env && { env: server.env }),
|
|
149
|
+
tools: ['*']
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { mcpServers };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
exports.CopilotCliFormat = CopilotCliFormat;
|
|
123
157
|
// Claude Desktop format (excludes provider servers - Issue #132)
|
|
124
158
|
class ClaudeFormat {
|
|
125
159
|
constructor() {
|
|
@@ -267,6 +301,7 @@ exports.IDE_FORMATS = {
|
|
|
267
301
|
kiro: new KiroFormat(),
|
|
268
302
|
vscode: new VSCodeFormat(),
|
|
269
303
|
'gemini-cli': new GeminiCliFormat(),
|
|
304
|
+
'copilot-cli': new CopilotCliFormat(),
|
|
270
305
|
claude: new ClaudeFormat(),
|
|
271
306
|
'claude-code': new ClaudeCodeFormat(),
|
|
272
307
|
windsurf: new WindsurfFormat(),
|
|
@@ -233,7 +233,7 @@ const autoConfigureMCP = async (fraimKey, selectedIDEs) => {
|
|
|
233
233
|
const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
|
|
234
234
|
if (detectedIDEs.length === 0 && (!selectedIDEs || selectedIDEs.length === 0)) {
|
|
235
235
|
console.log(chalk_1.default.yellow('⚠️ No supported IDEs detected.'));
|
|
236
|
-
console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, Kiro, Cursor, VSCode, Codex, Grok, Windsurf'));
|
|
236
|
+
console.log(chalk_1.default.gray('Supported IDEs: Claude, Claude Code, Antigravity, Gemini CLI, GitHub Copilot CLI, Kiro, Cursor, VSCode, Codex, Grok, Windsurf'));
|
|
237
237
|
console.log(chalk_1.default.blue('\n💡 You can install an IDE and run setup again later.'));
|
|
238
238
|
console.log(chalk_1.default.gray(' Or continue with manual MCP configuration.'));
|
|
239
239
|
if (process.env.FRAIM_NON_INTERACTIVE) {
|
|
@@ -121,6 +121,9 @@ const detectWindsurf = () => {
|
|
|
121
121
|
const detectGeminiCli = () => {
|
|
122
122
|
return availableByVersionProbe('gemini');
|
|
123
123
|
};
|
|
124
|
+
const detectCopilotCli = () => {
|
|
125
|
+
return availableByVersionProbe('copilot');
|
|
126
|
+
};
|
|
124
127
|
const detectGeminiSurface = () => {
|
|
125
128
|
const paths = [
|
|
126
129
|
'~/.gemini',
|
|
@@ -130,6 +133,15 @@ const detectGeminiSurface = () => {
|
|
|
130
133
|
];
|
|
131
134
|
return checkMultiplePaths(paths);
|
|
132
135
|
};
|
|
136
|
+
const detectCopilotSurface = () => {
|
|
137
|
+
const paths = [
|
|
138
|
+
process.env.COPILOT_HOME ? path_1.default.join(process.env.COPILOT_HOME, 'mcp-config.json') : '',
|
|
139
|
+
process.env.COPILOT_HOME || '',
|
|
140
|
+
'~/.copilot',
|
|
141
|
+
'~/.copilot/mcp-config.json'
|
|
142
|
+
].filter(Boolean);
|
|
143
|
+
return detectCopilotCli() || checkMultiplePaths(paths);
|
|
144
|
+
};
|
|
133
145
|
const detectCodexSurface = () => {
|
|
134
146
|
const paths = [
|
|
135
147
|
'~/.codex',
|
|
@@ -204,6 +216,20 @@ exports.IDE_CONFIGS = [
|
|
|
204
216
|
description: 'Google Gemini CLI local settings',
|
|
205
217
|
downloadUrl: 'https://github.com/google-gemini/gemini-cli',
|
|
206
218
|
},
|
|
219
|
+
{
|
|
220
|
+
name: 'GitHub Copilot CLI',
|
|
221
|
+
configPath: process.env.COPILOT_HOME
|
|
222
|
+
? path_1.default.join(process.env.COPILOT_HOME, 'mcp-config.json')
|
|
223
|
+
: '~/.copilot/mcp-config.json',
|
|
224
|
+
configFormat: 'json',
|
|
225
|
+
configType: 'copilot-cli',
|
|
226
|
+
invocationProfile: 'instructions-only',
|
|
227
|
+
detectMethod: detectCopilotSurface,
|
|
228
|
+
supportsConfigBootstrap: true,
|
|
229
|
+
aliases: ['copilot', 'copilot-cli', 'github copilot', 'github copilot cli'],
|
|
230
|
+
description: 'GitHub Copilot CLI local MCP settings',
|
|
231
|
+
downloadUrl: 'https://docs.github.com/copilot/how-tos/use-copilot-agents/use-copilot-cli',
|
|
232
|
+
},
|
|
207
233
|
{
|
|
208
234
|
name: 'Kiro',
|
|
209
235
|
configPath: '~/.kiro/settings/mcp.json',
|
|
@@ -305,7 +331,7 @@ const _cacheTimestamps = new Map();
|
|
|
305
331
|
const _cacheHomeDirs = new Map();
|
|
306
332
|
const DETECT_CACHE_TTL_MS = 5000;
|
|
307
333
|
/** configTypes that require a binary probe in cli-runnable mode; config folder alone is insufficient. */
|
|
308
|
-
exports.CLI_PROBE_CONFIGTYPES = new Set(['claude-code', 'codex', 'grok', 'gemini-cli']);
|
|
334
|
+
exports.CLI_PROBE_CONFIGTYPES = new Set(['claude-code', 'codex', 'grok', 'gemini-cli', 'copilot-cli']);
|
|
309
335
|
const isDetectedForMode = (ide, mode) => {
|
|
310
336
|
if (mode === 'cli-runnable') {
|
|
311
337
|
switch (ide.configType) {
|
|
@@ -317,6 +343,8 @@ const isDetectedForMode = (ide, mode) => {
|
|
|
317
343
|
return availableByVersionProbe('grok');
|
|
318
344
|
case 'gemini-cli':
|
|
319
345
|
return detectGeminiCli();
|
|
346
|
+
case 'copilot-cli':
|
|
347
|
+
return detectCopilotCli();
|
|
320
348
|
default:
|
|
321
349
|
return false;
|
|
322
350
|
}
|
|
@@ -55,7 +55,7 @@ async function installSlashCommands(homeDir) {
|
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
57
|
* Install FRAIM invocation artifacts for non-Claude IDEs.
|
|
58
|
-
* Supports: Cursor, Codex, Grok, Gemini CLI, Windsurf, Kiro
|
|
58
|
+
* Supports: Cursor, Codex, Grok, Gemini CLI, Copilot CLI, Windsurf, Kiro
|
|
59
59
|
* Does not overwrite existing files.
|
|
60
60
|
*/
|
|
61
61
|
async function installGlobalRules(homeDir) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.generateMCPConfig = exports.generateWindsurfMCPServers = exports.generateGeminiCliMCPServers = exports.generateVSCodeMCPServers = exports.generateGrokMCPServers = exports.generateCodexMCPServers = exports.generateKiroMCPServers = exports.generateClaudeCodeMCPServers = exports.generateClaudeMCPServers = exports.generateStandardMCPServers = exports.mergeTomlMCPServers = exports.extractTomlMcpServerBlock = void 0;
|
|
3
|
+
exports.generateMCPConfig = exports.generateWindsurfMCPServers = exports.generateCopilotCliMCPServers = exports.generateGeminiCliMCPServers = exports.generateVSCodeMCPServers = exports.generateGrokMCPServers = exports.generateCodexMCPServers = exports.generateKiroMCPServers = exports.generateClaudeCodeMCPServers = exports.generateClaudeMCPServers = exports.generateStandardMCPServers = exports.mergeTomlMCPServers = exports.extractTomlMcpServerBlock = void 0;
|
|
4
4
|
const mcp_server_builder_1 = require("../mcp/mcp-server-builder");
|
|
5
5
|
const ide_formats_1 = require("../mcp/ide-formats");
|
|
6
6
|
const normalizeTokens = (tokenInput) => {
|
|
@@ -178,6 +178,15 @@ const generateGeminiCliMCPServers = async (fraimKey, tokenInput, providerConfigs
|
|
|
178
178
|
return format.transform(builder.getServers());
|
|
179
179
|
};
|
|
180
180
|
exports.generateGeminiCliMCPServers = generateGeminiCliMCPServers;
|
|
181
|
+
const generateCopilotCliMCPServers = async (fraimKey, tokenInput, providerConfigs) => {
|
|
182
|
+
const tokens = normalizeTokens(tokenInput);
|
|
183
|
+
const builder = new mcp_server_builder_1.MCPServerBuilder();
|
|
184
|
+
builder.addBaseServers(fraimKey);
|
|
185
|
+
await addProviderServers(builder, tokens, providerConfigs);
|
|
186
|
+
const format = (0, ide_formats_1.getIDEFormat)('copilot-cli');
|
|
187
|
+
return format.transform(builder.getServers());
|
|
188
|
+
};
|
|
189
|
+
exports.generateCopilotCliMCPServers = generateCopilotCliMCPServers;
|
|
181
190
|
const generateWindsurfMCPServers = async (fraimKey, tokenInput, providerConfigs) => {
|
|
182
191
|
const tokens = normalizeTokens(tokenInput);
|
|
183
192
|
const builder = new mcp_server_builder_1.MCPServerBuilder();
|
|
@@ -201,6 +210,8 @@ const generateMCPConfig = async (configType, fraimKey, tokenInput, providerConfi
|
|
|
201
210
|
return await (0, exports.generateVSCodeMCPServers)(fraimKey, tokenInput, providerConfigs);
|
|
202
211
|
case 'gemini-cli':
|
|
203
212
|
return await (0, exports.generateGeminiCliMCPServers)(fraimKey, tokenInput, providerConfigs);
|
|
213
|
+
case 'copilot-cli':
|
|
214
|
+
return await (0, exports.generateCopilotCliMCPServers)(fraimKey, tokenInput, providerConfigs);
|
|
204
215
|
case 'codex':
|
|
205
216
|
return await (0, exports.generateCodexMCPServers)(fraimKey, tokenInput, providerConfigs);
|
|
206
217
|
case 'grok':
|
|
@@ -21,6 +21,9 @@ const GEMINI_FRAIM_COMMAND_PATH = path_1.default.join('.gemini', 'commands', 'fr
|
|
|
21
21
|
const GEMINI_PROJECT_INSTRUCTIONS_PATH = path_1.default.join('.gemini', 'GEMINI.md');
|
|
22
22
|
const WINDSURF_FRAIM_COMMAND_PATH = path_1.default.join('.windsurf', 'commands', 'fraim.md');
|
|
23
23
|
const KIRO_FRAIM_COMMAND_PATH = path_1.default.join('.kiro', 'commands', 'fraim.md');
|
|
24
|
+
function adapterConfigTypes(file) {
|
|
25
|
+
return Array.isArray(file.configType) ? file.configType : [file.configType];
|
|
26
|
+
}
|
|
24
27
|
function buildManagedSection(body) {
|
|
25
28
|
return `${START_MARKER}
|
|
26
29
|
${body.trim()}
|
|
@@ -113,7 +116,7 @@ ${(0, ide_invocation_surfaces_1.buildFraimInvocationBody)('generic-tool-discover
|
|
|
113
116
|
const all = [
|
|
114
117
|
{ path: 'AGENTS.md', content: markdownBody, configType: 'standard' },
|
|
115
118
|
{ path: 'CLAUDE.md', content: markdownBody, configType: 'standard' },
|
|
116
|
-
{ path: path_1.default.join('.github', 'copilot-instructions.md'), content: copilotBody, configType: 'vscode' },
|
|
119
|
+
{ path: path_1.default.join('.github', 'copilot-instructions.md'), content: copilotBody, configType: ['vscode', 'copilot-cli'] },
|
|
117
120
|
{ path: CURSOR_RULE_PATH, content: cursorManagedBody, configType: 'cursor' },
|
|
118
121
|
{ path: VSCODE_FRAIM_PROMPT_PATH, content: vscodePrompt, configType: 'vscode' },
|
|
119
122
|
{ path: path_1.default.join(project_fraim_paths_1.WORKSPACE_FRAIM_DIRNAME, 'README.md'), content: fraimReadme, configType: 'standard' },
|
|
@@ -129,7 +132,10 @@ ${(0, ide_invocation_surfaces_1.buildFraimInvocationBody)('generic-tool-discover
|
|
|
129
132
|
if (allowedConfigTypes === null) {
|
|
130
133
|
return all;
|
|
131
134
|
}
|
|
132
|
-
return all.filter(f =>
|
|
135
|
+
return all.filter((f) => {
|
|
136
|
+
const types = adapterConfigTypes(f);
|
|
137
|
+
return types.includes('standard') || types.some((type) => allowedConfigTypes.includes(type));
|
|
138
|
+
});
|
|
133
139
|
}
|
|
134
140
|
function getAdapterConfigTypes() {
|
|
135
141
|
const result = {};
|
|
@@ -53,7 +53,7 @@ exports.FIRST_RUN_AGENT_OPTIONS = [
|
|
|
53
53
|
{
|
|
54
54
|
id: 'copilot-cli',
|
|
55
55
|
label: 'GitHub Copilot',
|
|
56
|
-
detectAliases: ['copilot'],
|
|
56
|
+
detectAliases: ['copilot', 'copilot-cli', 'github copilot cli'],
|
|
57
57
|
loginCommand: 'copilot login',
|
|
58
58
|
launchCommand: 'copilot',
|
|
59
59
|
installPackage: '@github/copilot',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-framework",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.187",
|
|
4
4
|
"description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/public/ai-hub/script.js
CHANGED
|
@@ -20,6 +20,21 @@ const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements
|
|
|
20
20
|
// listed here — a persona can run a job from a project OR from the Manager tab, and a
|
|
21
21
|
// run's placement is decided per-invocation by conv.invokedArea, not by job id.
|
|
22
22
|
const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements']);
|
|
23
|
+
// Issue #708: project-independent conversation scope buckets (match server sentinels).
|
|
24
|
+
const MANAGER_CONV_KEY = '@manager';
|
|
25
|
+
const COMPANY_CONV_KEY = '@company';
|
|
26
|
+
// A conversation's scope: prefer the persisted `scope`, fall back to the legacy `invokedArea`.
|
|
27
|
+
function convScope(conv) {
|
|
28
|
+
const s = conv && (conv.scope || conv.invokedArea);
|
|
29
|
+
return (s === 'manager' || s === 'company') ? s : 'project';
|
|
30
|
+
}
|
|
31
|
+
// Which state.conversations bucket a conversation belongs to.
|
|
32
|
+
function convBucketKey(conv) {
|
|
33
|
+
const s = convScope(conv);
|
|
34
|
+
if (s === 'manager') return MANAGER_CONV_KEY;
|
|
35
|
+
if (s === 'company') return COMPANY_CONV_KEY;
|
|
36
|
+
return (conv && typeof conv.projectPath === 'string' && conv.projectPath) ? conv.projectPath : state.projectPath;
|
|
37
|
+
}
|
|
23
38
|
|
|
24
39
|
const state = {
|
|
25
40
|
bootstrap: null,
|
|
@@ -120,7 +135,11 @@ async function requestJson(url, options) {
|
|
|
120
135
|
if (!response.ok) {
|
|
121
136
|
const errVal = payload && payload.error;
|
|
122
137
|
const message = (errVal && typeof errVal === 'object' ? errVal.message : errVal) || `Request failed (${response.status}).`;
|
|
123
|
-
|
|
138
|
+
const error = new Error(message);
|
|
139
|
+
// #719: callers branch on the HTTP status (e.g. DELETE 404 already-removed vs
|
|
140
|
+
// 409 sequencing error) — carry it on the thrown error.
|
|
141
|
+
error.status = response.status;
|
|
142
|
+
throw error;
|
|
124
143
|
}
|
|
125
144
|
return payload;
|
|
126
145
|
}
|
|
@@ -447,16 +466,30 @@ function projectConversationPayload() {
|
|
|
447
466
|
}
|
|
448
467
|
|
|
449
468
|
function scheduleConversationDiskPersist() {
|
|
450
|
-
|
|
469
|
+
// Issue #708: no early-return on missing projectPath — manager/company scope buckets
|
|
470
|
+
// must persist even when no project is selected (their whole point is project-independence).
|
|
451
471
|
if (state.conversationPersistTimer) window.clearTimeout(state.conversationPersistTimer);
|
|
452
472
|
state.conversationPersistTimer = window.setTimeout(async () => {
|
|
453
473
|
state.conversationPersistTimer = null;
|
|
454
474
|
try {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
475
|
+
if (state.projectPath) {
|
|
476
|
+
await requestJson('/api/ai-hub/conversations', {
|
|
477
|
+
method: 'PUT',
|
|
478
|
+
headers: { 'Content-Type': 'application/json' },
|
|
479
|
+
body: JSON.stringify(projectConversationPayload()),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
// Issue #708: persist scope buckets (manager/company) to their project-independent
|
|
483
|
+
// home so their runs survive project switches/removal and reload correctly.
|
|
484
|
+
for (const [scope, key] of [['manager', MANAGER_CONV_KEY], ['company', COMPANY_CONV_KEY]]) {
|
|
485
|
+
const bucket = state.conversations[key];
|
|
486
|
+
if (!Array.isArray(bucket)) continue;
|
|
487
|
+
await requestJson('/api/ai-hub/conversations', {
|
|
488
|
+
method: 'PUT',
|
|
489
|
+
headers: { 'Content-Type': 'application/json' },
|
|
490
|
+
body: JSON.stringify({ scope, activeId: null, conversations: bucket }),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
460
493
|
state.conversationDiskAvailable = true;
|
|
461
494
|
} catch (error) {
|
|
462
495
|
state.conversationDiskAvailable = false;
|
|
@@ -471,7 +504,28 @@ function persistConversations(options) {
|
|
|
471
504
|
}
|
|
472
505
|
|
|
473
506
|
async function hydrateConversationsFromServer() {
|
|
474
|
-
|
|
507
|
+
// Issue #708: load the project-independent scope buckets (manager/company) first, so
|
|
508
|
+
// manager runs are present regardless of whether a project is selected.
|
|
509
|
+
try {
|
|
510
|
+
for (const [scope, key] of [['manager', MANAGER_CONV_KEY], ['company', COMPANY_CONV_KEY]]) {
|
|
511
|
+
const scopePayload = await requestJson(`/api/ai-hub/conversations?scope=${scope}`);
|
|
512
|
+
const scopeConvs = Array.isArray(scopePayload.conversations) ? scopePayload.conversations : [];
|
|
513
|
+
for (const conv of scopeConvs) normalizeGeminiConversationMessages(conv);
|
|
514
|
+
// Only materialize a scope bucket when it has content (or already exists). Creating
|
|
515
|
+
// empty @manager/@company buckets on every project hydrate pollutes state.conversations
|
|
516
|
+
// and, because they land ahead of the project bucket, breaks the "project bucket is the
|
|
517
|
+
// primary key" invariant that project-scoped rendering relies on (#550 Watercooler).
|
|
518
|
+
if (scopeConvs.length || Array.isArray(state.conversations[key])) {
|
|
519
|
+
state.conversations[key] = scopeConvs;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
} catch (error) {
|
|
523
|
+
console.warn('Could not hydrate manager/company conversations:', error);
|
|
524
|
+
}
|
|
525
|
+
if (!state.projectPath) {
|
|
526
|
+
if (typeof tfRenderManager === 'function' && typeof tf !== 'undefined' && tf.area === 'manager') tfRenderManager();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
475
529
|
try {
|
|
476
530
|
const payload = await requestJson(`/api/ai-hub/conversations?projectPath=${encodeURIComponent(state.projectPath)}`);
|
|
477
531
|
const conversations = Array.isArray(payload.conversations) ? payload.conversations : [];
|
|
@@ -810,12 +864,15 @@ function syncConversationPanels(conv, switchedConv) {
|
|
|
810
864
|
}
|
|
811
865
|
|
|
812
866
|
function upsertConversation(conv) {
|
|
813
|
-
|
|
867
|
+
// Issue #708: route to the conversation's scope bucket (manager/company → sentinel
|
|
868
|
+
// key; project → its project bucket) so manager runs have a project-independent home.
|
|
869
|
+
const key = convBucketKey(conv);
|
|
870
|
+
const list = (state.conversations[key] || []).slice();
|
|
814
871
|
const idx = list.findIndex((c) => c.id === conv.id);
|
|
815
872
|
if (idx >= 0) list[idx] = conv;
|
|
816
873
|
else list.unshift(conv);
|
|
817
|
-
|
|
818
|
-
persistConversations();
|
|
874
|
+
state.conversations[key] = list;
|
|
875
|
+
persistConversations({ scope: convScope(conv) });
|
|
819
876
|
}
|
|
820
877
|
|
|
821
878
|
function newConversationId() {
|
|
@@ -922,8 +979,8 @@ function renderRail() {
|
|
|
922
979
|
(!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
|
|
923
980
|
!isManagedDelegationChild(conv) &&
|
|
924
981
|
!AREA_SCOPED_JOBS.has(conv.jobId) &&
|
|
925
|
-
// #702: runs
|
|
926
|
-
conv
|
|
982
|
+
// #702/#708: manager/company-scoped runs surface on those tabs, not in a project.
|
|
983
|
+
convScope(conv) === 'project'
|
|
927
984
|
);
|
|
928
985
|
|
|
929
986
|
// Issue #550: Two-path routing for ad-hoc (freeform) conversations.
|
|
@@ -4883,6 +4940,12 @@ async function startRun(job, instructions, employeeId, preassignedConvId, invoke
|
|
|
4883
4940
|
// where it was invoked (project-invoked → that project; manager-invoked → Manager tab).
|
|
4884
4941
|
// Defaults to the current area.
|
|
4885
4942
|
invokedArea: invokedArea || (typeof tf !== 'undefined' && tf.area) || 'projects',
|
|
4943
|
+
// Issue #708: persisted scope drives which store bucket the run lives in
|
|
4944
|
+
// (manager/company get a project-independent home). Derived from invocation area.
|
|
4945
|
+
scope: (function () {
|
|
4946
|
+
const a = invokedArea || (typeof tf !== 'undefined' && tf.area) || 'projects';
|
|
4947
|
+
return (a === 'manager' || a === 'company') ? a : 'project';
|
|
4948
|
+
})(),
|
|
4886
4949
|
};
|
|
4887
4950
|
upsertConversation(conv);
|
|
4888
4951
|
state.activeId = conv.id;
|
|
@@ -6604,6 +6667,9 @@ function tfRenderOverview() {
|
|
|
6604
6667
|
}
|
|
6605
6668
|
card.appendChild(team);
|
|
6606
6669
|
card.addEventListener('click', () => tfSelectProjectView('workspace', proj.id));
|
|
6670
|
+
// #719 R1/R1b: hover/focus-revealed remove control — Overview cards only, and
|
|
6671
|
+
// only while another project remains (the Hub always keeps at least one).
|
|
6672
|
+
if (tf.projects.length > 1) tfAttachProjectRemove(card, proj);
|
|
6607
6673
|
grid.appendChild(card);
|
|
6608
6674
|
}
|
|
6609
6675
|
const addCard = document.createElement('div');
|
|
@@ -6616,6 +6682,167 @@ function tfRenderOverview() {
|
|
|
6616
6682
|
tfSwitchOverviewView(storedView || (tf.projects.length >= 3 ? 'kanban' : 'cards'));
|
|
6617
6683
|
}
|
|
6618
6684
|
|
|
6685
|
+
// ---------------------------------------------------------------------------
|
|
6686
|
+
// Issue #719: remove a project from the Hub (Overview card 🗑 + inline confirm)
|
|
6687
|
+
// ---------------------------------------------------------------------------
|
|
6688
|
+
|
|
6689
|
+
// The card-positioned sibling of tfAttachRunDelete: a hover/focus-revealed 🗑 in
|
|
6690
|
+
// the card's top-right. Keyboard-operable (tabindex + Enter/Space) per R11.
|
|
6691
|
+
function tfAttachProjectRemove(cardEl, proj) {
|
|
6692
|
+
if (!cardEl || !proj) return;
|
|
6693
|
+
const del = document.createElement('span');
|
|
6694
|
+
del.className = 'proj-del';
|
|
6695
|
+
del.textContent = '🗑';
|
|
6696
|
+
del.title = 'Remove this project from the Hub';
|
|
6697
|
+
del.setAttribute('role', 'button');
|
|
6698
|
+
del.setAttribute('aria-label', 'Remove project from Hub');
|
|
6699
|
+
del.tabIndex = 0;
|
|
6700
|
+
del.addEventListener('click', (e) => {
|
|
6701
|
+
e.preventDefault();
|
|
6702
|
+
e.stopPropagation();
|
|
6703
|
+
tfConfirmProjectRemove(proj, cardEl);
|
|
6704
|
+
});
|
|
6705
|
+
del.addEventListener('keydown', (e) => {
|
|
6706
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
6707
|
+
e.preventDefault();
|
|
6708
|
+
e.stopPropagation();
|
|
6709
|
+
tfConfirmProjectRemove(proj, cardEl);
|
|
6710
|
+
});
|
|
6711
|
+
cardEl.appendChild(del);
|
|
6712
|
+
}
|
|
6713
|
+
|
|
6714
|
+
function tfCloseProjectConfirm() {
|
|
6715
|
+
document.querySelectorAll('.del-confirm').forEach((n) => n.remove());
|
|
6716
|
+
document.removeEventListener('keydown', tfProjectConfirmEscape, true);
|
|
6717
|
+
}
|
|
6718
|
+
function tfProjectConfirmEscape(e) {
|
|
6719
|
+
if (e.key === 'Escape') tfCloseProjectConfirm();
|
|
6720
|
+
}
|
|
6721
|
+
|
|
6722
|
+
// Inline confirm anchored inside the card — the run-delete idiom (tfConfirmRunDelete)
|
|
6723
|
+
// with the project scope-of-loss copy. Loads run/schedule state from the server so
|
|
6724
|
+
// the copy can state the schedule count (R6) and block removal while runs are in
|
|
6725
|
+
// progress (R7). Nothing is deleted until the confirm button is clicked (C1).
|
|
6726
|
+
function tfConfirmProjectRemove(proj, cardEl) {
|
|
6727
|
+
tfCloseProjectConfirm();
|
|
6728
|
+
const box = document.createElement('div');
|
|
6729
|
+
box.className = 'del-confirm';
|
|
6730
|
+
// Any click inside the confirm must not bubble into the card's open-workspace handler.
|
|
6731
|
+
box.addEventListener('click', (e) => e.stopPropagation());
|
|
6732
|
+
const msg = document.createElement('div');
|
|
6733
|
+
msg.textContent = 'Remove "' + (proj.name || 'this project') + '" from the Hub? Its run history, job assignments, and Hub settings are forgotten. The project’s files on disk are untouched.';
|
|
6734
|
+
const detail = document.createElement('div');
|
|
6735
|
+
detail.className = 'dc-detail';
|
|
6736
|
+
detail.textContent = 'Checking for runs and schedules…';
|
|
6737
|
+
const row = document.createElement('div'); row.className = 'dc-row';
|
|
6738
|
+
const del = document.createElement('button'); del.className = 'dc-del'; del.type = 'button'; del.textContent = 'Remove project';
|
|
6739
|
+
del.disabled = true; // enabled once the run/schedule check resolves (C1/R7)
|
|
6740
|
+
del.addEventListener('click', (e) => {
|
|
6741
|
+
e.stopPropagation();
|
|
6742
|
+
if (del.disabled) return;
|
|
6743
|
+
tfCloseProjectConfirm();
|
|
6744
|
+
tfRemoveProject(proj);
|
|
6745
|
+
});
|
|
6746
|
+
const cancel = document.createElement('button'); cancel.className = 'dc-cancel'; cancel.type = 'button'; cancel.textContent = 'Cancel';
|
|
6747
|
+
cancel.addEventListener('click', (e) => { e.stopPropagation(); tfCloseProjectConfirm(); });
|
|
6748
|
+
row.appendChild(del); row.appendChild(cancel);
|
|
6749
|
+
box.appendChild(msg); box.appendChild(detail); box.appendChild(row);
|
|
6750
|
+
cardEl.appendChild(box);
|
|
6751
|
+
document.addEventListener('keydown', tfProjectConfirmEscape, true);
|
|
6752
|
+
|
|
6753
|
+
const encodedPath = encodeURIComponent(proj.folderPath || '');
|
|
6754
|
+
Promise.all([
|
|
6755
|
+
requestJson('/api/ai-hub/conversations?projectPath=' + encodedPath).catch(() => null),
|
|
6756
|
+
requestJson('/api/ai-hub/schedules?projectPath=' + encodedPath).catch(() => []),
|
|
6757
|
+
requestJson('/api/ai-hub/webhooks?projectPath=' + encodedPath).catch(() => []),
|
|
6758
|
+
]).then(([convPayload, schedules, webhooks]) => {
|
|
6759
|
+
if (!box.isConnected) return;
|
|
6760
|
+
const serverConvs = (convPayload && Array.isArray(convPayload.conversations)) ? convPayload.conversations : [];
|
|
6761
|
+
const cachedConvs = state.conversations[proj.folderPath] || [];
|
|
6762
|
+
const running = [...serverConvs, ...cachedConvs].some((c) => c && c.status === 'running');
|
|
6763
|
+
if (running) {
|
|
6764
|
+
// R7: never forget in-flight work — block until the runs finish or are stopped.
|
|
6765
|
+
detail.textContent = 'Runs are in progress in this project. Stop them or wait for them to finish before removing.';
|
|
6766
|
+
del.disabled = true;
|
|
6767
|
+
return;
|
|
6768
|
+
}
|
|
6769
|
+
const scheduleCount = Array.isArray(schedules) ? schedules.length : 0;
|
|
6770
|
+
const webhookCount = Array.isArray(webhooks) ? webhooks.length : 0;
|
|
6771
|
+
const parts = [];
|
|
6772
|
+
if (scheduleCount) parts.push(scheduleCount + ' scheduled deployment' + (scheduleCount === 1 ? '' : 's'));
|
|
6773
|
+
if (webhookCount) parts.push(webhookCount + ' webhook' + (webhookCount === 1 ? '' : 's'));
|
|
6774
|
+
detail.textContent = parts.length ? (parts.join(' and ') + ' will also be removed.') : '';
|
|
6775
|
+
if (!parts.length) detail.remove();
|
|
6776
|
+
del.disabled = false;
|
|
6777
|
+
});
|
|
6778
|
+
}
|
|
6779
|
+
|
|
6780
|
+
// R10: adopt a server projects list as the authoritative tf.projects — a REPLACE,
|
|
6781
|
+
// not a tfMergeProjects union, so a later tfPersistProjects PUT cannot re-add a
|
|
6782
|
+
// removed project from stale in-memory state.
|
|
6783
|
+
function tfAdoptServerProjects(projects) {
|
|
6784
|
+
const normalized = (Array.isArray(projects) ? projects : [])
|
|
6785
|
+
.map((project) => tfNormalizeProject(project))
|
|
6786
|
+
.filter(Boolean);
|
|
6787
|
+
tf.projects = tfApplyUniqueProjectIds(normalized);
|
|
6788
|
+
}
|
|
6789
|
+
|
|
6790
|
+
async function tfRemoveProject(proj) {
|
|
6791
|
+
const removedCanonical = tfCanonicalProjectPath(proj.folderPath || '');
|
|
6792
|
+
// R5/D4: never delete the workspace we are standing in — the server re-injects
|
|
6793
|
+
// the current projectPath on every load, so switch to another project first.
|
|
6794
|
+
if (removedCanonical === tfCanonicalProjectPath(state.projectPath)) {
|
|
6795
|
+
const next = tf.projects.find((p) => p.id !== proj.id && tfCanonicalProjectPath(p.folderPath || '') !== removedCanonical);
|
|
6796
|
+
if (!next) { showStatus('Cannot remove the only project in the Hub.', true); return; }
|
|
6797
|
+
try {
|
|
6798
|
+
await tfSwitchProjectFolder(next.folderPath);
|
|
6799
|
+
tf.activeProjectId = next.id;
|
|
6800
|
+
} catch (e) {
|
|
6801
|
+
showStatus('Could not switch to another project first; nothing was removed.', true);
|
|
6802
|
+
return;
|
|
6803
|
+
}
|
|
6804
|
+
}
|
|
6805
|
+
|
|
6806
|
+
let payload;
|
|
6807
|
+
try {
|
|
6808
|
+
payload = await requestJson(
|
|
6809
|
+
'/api/ai-hub/projects/' + encodeURIComponent(proj.id) + '?projectPath=' + encodeURIComponent(state.projectPath),
|
|
6810
|
+
{ method: 'DELETE' },
|
|
6811
|
+
);
|
|
6812
|
+
} catch (error) {
|
|
6813
|
+
if (error && error.status === 404) {
|
|
6814
|
+
// Already removed elsewhere (double-click / second tab): treat as success
|
|
6815
|
+
// for rendering and adopt the server's current list.
|
|
6816
|
+
try { payload = { projects: (await requestJson('/api/ai-hub/projects?projectPath=' + encodeURIComponent(state.projectPath))).projects }; }
|
|
6817
|
+
catch { payload = { projects: tf.projects.filter((p) => p.id !== proj.id) }; }
|
|
6818
|
+
showStatus('That project was already removed.', false);
|
|
6819
|
+
} else if (error && error.status === 409) {
|
|
6820
|
+
showStatus('Switch to another project first, then remove this one.', true);
|
|
6821
|
+
return;
|
|
6822
|
+
} else {
|
|
6823
|
+
// Server unreachable or rejected: no optimistic client mutation.
|
|
6824
|
+
showStatus('Could not remove the project: ' + (error && error.message ? error.message : 'request failed.'), true);
|
|
6825
|
+
return;
|
|
6826
|
+
}
|
|
6827
|
+
}
|
|
6828
|
+
|
|
6829
|
+
// R3/R10: drop every client layer, adopting the server list as authoritative.
|
|
6830
|
+
tfAdoptServerProjects(payload && payload.projects);
|
|
6831
|
+
delete tf.assignments[proj.id];
|
|
6832
|
+
tfPersistAssignments();
|
|
6833
|
+
for (const key of Object.keys(state.conversations || {})) {
|
|
6834
|
+
if (tfCanonicalProjectPath(key) === removedCanonical) delete state.conversations[key];
|
|
6835
|
+
}
|
|
6836
|
+
if (!tf.projects.some((p) => p.id === tf.activeProjectId)) {
|
|
6837
|
+
tf.activeProjectId = tf.projects[0] ? tf.projects[0].id : null;
|
|
6838
|
+
}
|
|
6839
|
+
tfPersistProjects();
|
|
6840
|
+
tfRenderProjectTabs();
|
|
6841
|
+
tfRenderOverview();
|
|
6842
|
+
if (typeof renderRail === 'function') renderRail();
|
|
6843
|
+
showStatus('"' + (proj.name || 'Project') + '" was removed from the Hub. Its files on disk are untouched.', false);
|
|
6844
|
+
}
|
|
6845
|
+
|
|
6619
6846
|
// ---------------------------------------------------------------------------
|
|
6620
6847
|
// Issue #671: Kanban view for Projects Overview
|
|
6621
6848
|
// ---------------------------------------------------------------------------
|
|
@@ -8121,7 +8348,7 @@ function tfActiveOrgConv() {
|
|
|
8121
8348
|
// plus the area-only manager-agreements job (older runs predate invokedArea). An active
|
|
8122
8349
|
// manager-invoked run surfaces in the Manager conversation host (Coach panel included).
|
|
8123
8350
|
function tfActiveMgrConv() {
|
|
8124
|
-
const convs = Object.values(state.conversations || {}).flat().filter((c) => c && (c
|
|
8351
|
+
const convs = Object.values(state.conversations || {}).flat().filter((c) => c && (convScope(c) === 'manager' || c.jobId === 'manager-agreements'));
|
|
8125
8352
|
return (
|
|
8126
8353
|
convs.find((c) => c.status === 'running') ||
|
|
8127
8354
|
convs.find((c) => c.status === 'waiting') ||
|
|
@@ -8177,7 +8404,7 @@ function tfBuildManagerRunItem(conv) {
|
|
|
8177
8404
|
function tfRenderManagerPersonaGroup(rail, persona) {
|
|
8178
8405
|
const sample = { personaKey: persona.key };
|
|
8179
8406
|
const runs = Object.values(state.conversations || {}).flat()
|
|
8180
|
-
.filter((c) => c && c.personaKey === persona.key && c
|
|
8407
|
+
.filter((c) => c && c.personaKey === persona.key && convScope(c) === 'manager')
|
|
8181
8408
|
.sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
|
|
8182
8409
|
const details = document.createElement('details');
|
|
8183
8410
|
details.className = 'conv-employee-group';
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -3065,7 +3065,26 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
3065
3065
|
.del-confirm .dc-row { display: flex; gap: 8px; margin-top: 8px; }
|
|
3066
3066
|
.del-confirm button { font: inherit; font-size: 12px; border-radius: 7px; padding: 5px 12px; cursor: pointer; }
|
|
3067
3067
|
.del-confirm .dc-del { background: var(--danger, #d2261f); color: #fff; border: none; }
|
|
3068
|
+
.del-confirm .dc-del:disabled { opacity: .55; cursor: not-allowed; }
|
|
3068
3069
|
.del-confirm .dc-cancel { background: var(--surface); border: 1px solid var(--line); color: var(--text); }
|
|
3070
|
+
.del-confirm .dc-detail { margin-top: 6px; color: var(--muted); }
|
|
3071
|
+
.del-confirm .dc-detail:empty { display: none; }
|
|
3072
|
+
|
|
3073
|
+
/* #719 — remove a project from the Hub. The card-positioned copy of .run-del.
|
|
3074
|
+
Hidden via opacity (not display) so the control stays keyboard-focusable
|
|
3075
|
+
(R11: revealed on hover AND on keyboard focus). */
|
|
3076
|
+
.proj-card { position: relative; }
|
|
3077
|
+
.proj-card .proj-del { position: absolute; right: 10px; top: 10px;
|
|
3078
|
+
width: 22px; height: 22px; border-radius: 6px; border: 1px solid var(--line);
|
|
3079
|
+
background: var(--surface); color: var(--muted); font-size: 12px; line-height: 1;
|
|
3080
|
+
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
|
3081
|
+
opacity: 0; pointer-events: none; }
|
|
3082
|
+
.proj-card:hover .proj-del, .proj-card .proj-del:focus-visible, .proj-card .proj-del:focus {
|
|
3083
|
+
opacity: 1; pointer-events: auto; }
|
|
3084
|
+
.proj-card .proj-del:hover { color: var(--danger, #d2261f); border-color: var(--danger, #d2261f); }
|
|
3085
|
+
/* Inside a card the confirm sits below the team row — zero the horizontal
|
|
3086
|
+
margin the run-list variant carries. */
|
|
3087
|
+
.proj-card .del-confirm { margin: 10px 0 0; }
|
|
3069
3088
|
|
|
3070
3089
|
/* R1 (#521): employees are the heroes — large accordion rows replace .tree-emp.
|
|
3071
3090
|
The summary is the employee (40px avatar, name, role, status dot); their jobs
|