@ctrl-spc/cli 1.1.15 → 1.2.0
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/agents.js +54 -0
- package/dist/collision.js +44 -0
- package/dist/commands/fetch.js +213 -0
- package/dist/commands/init.js +457 -0
- package/dist/commands/link.js +152 -0
- package/dist/commands/login.js +332 -0
- package/dist/commands/logout.js +14 -0
- package/dist/commands/run.js +622 -0
- package/dist/config.js +68 -0
- package/dist/env.js +5 -0
- package/dist/git.js +70 -0
- package/dist/index.js +56 -72
- package/dist/mcp.js +1732 -0
- package/dist/presence.js +62 -0
- package/dist/prompt.js +47 -0
- package/dist/protocol.js +117 -0
- package/dist/skills.js +148 -0
- package/dist/supabase.js +40 -10
- package/dist/topology.js +602 -0
- package/package.json +25 -23
- package/bin/ctrl-spc.js +0 -6
- package/dist/auth.js +0 -172
- package/dist/controlRequests.js +0 -164
- package/dist/daemon.js +0 -152
- package/dist/devices.js +0 -64
- package/dist/flagAssembler.js +0 -29
- package/dist/init.js +0 -79
- package/dist/launchd.js +0 -154
- package/dist/lifecycle.js +0 -37
- package/dist/mcpConfig.js +0 -12
- package/dist/repo.js +0 -52
- package/dist/session.js +0 -58
- package/dist/setup.js +0 -60
- package/dist/spawner.js +0 -140
- package/dist/windows-service.js +0 -142
- package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.d.ts +0 -24
- package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.js +0 -32
- package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.d.ts +0 -2
- package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.js +0 -21
- package/node_modules/@ctrl-spc/mcp-server/dist/index.d.ts +0 -1
- package/node_modules/@ctrl-spc/mcp-server/dist/index.js +0 -22
- package/node_modules/@ctrl-spc/mcp-server/package.json +0 -38
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
6
|
+
import { getClient } from '../supabase.js';
|
|
7
|
+
import { currentRepoRemote, normalizeRemoteUrl } from '../git.js';
|
|
8
|
+
import { detectAgents, resolveAgentCommand } from '../agents.js';
|
|
9
|
+
import { claudeSkillPath, codexSkillPath, installSkills } from '../skills.js';
|
|
10
|
+
import { startPresence } from '../presence.js';
|
|
11
|
+
import { MCP_PORT } from '../env.js';
|
|
12
|
+
import { startMcpServer } from '../mcp.js';
|
|
13
|
+
import { getMachineIdentity } from '../config.js';
|
|
14
|
+
import { discoverRepositoryTopology } from '../topology.js';
|
|
15
|
+
function mcpUrl() {
|
|
16
|
+
return `http://localhost:${MCP_PORT}/mcp`;
|
|
17
|
+
}
|
|
18
|
+
export async function existingBrokerStatus(machineId, port = MCP_PORT, fetcher = fetch) {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetcher(`http://127.0.0.1:${port}/health`, {
|
|
21
|
+
method: 'GET',
|
|
22
|
+
signal: AbortSignal.timeout(750),
|
|
23
|
+
});
|
|
24
|
+
if (!response.ok)
|
|
25
|
+
return 'not_running';
|
|
26
|
+
const health = await response.json();
|
|
27
|
+
if (health.status !== 'ok' || !health.machine_id)
|
|
28
|
+
return 'not_running';
|
|
29
|
+
return health.machine_id === machineId ? 'same_machine' : 'different_machine';
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return 'not_running';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function one(value) {
|
|
36
|
+
return Array.isArray(value) ? value[0] ?? null : value;
|
|
37
|
+
}
|
|
38
|
+
function isWithin(parent, child) {
|
|
39
|
+
const path = relative(parent, child);
|
|
40
|
+
return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path));
|
|
41
|
+
}
|
|
42
|
+
function gitText(cwd, args) {
|
|
43
|
+
try {
|
|
44
|
+
const output = execFileSync('git', args, {
|
|
45
|
+
cwd,
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
+
}).trim();
|
|
49
|
+
return output || null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Inspect only a path already discovered or previously registered. */
|
|
56
|
+
export function inspectLocalCheckout(path) {
|
|
57
|
+
if (!existsSync(path))
|
|
58
|
+
return null;
|
|
59
|
+
const remote = currentRepoRemote(path);
|
|
60
|
+
const gitDir = gitText(path, ['rev-parse', '--path-format=absolute', '--git-dir']);
|
|
61
|
+
const commonDir = gitText(path, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
62
|
+
return {
|
|
63
|
+
localPath: path,
|
|
64
|
+
remoteKey: remote ? normalizeRemoteUrl(remote) : null,
|
|
65
|
+
headSha: gitText(path, ['rev-parse', 'HEAD']),
|
|
66
|
+
isWorktree: Boolean(gitDir && commonDir && gitDir !== commonDir),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Discovers checkouts from real Git roots only. Alternate linked worktrees are
|
|
71
|
+
* inspected independently; a repository without a verifiable origin is kept
|
|
72
|
+
* as an observation but can never be matched to a hosted repository record.
|
|
73
|
+
*/
|
|
74
|
+
export function observeLocalWorkspace(cwd) {
|
|
75
|
+
const topology = discoverRepositoryTopology(cwd);
|
|
76
|
+
const paths = new Set();
|
|
77
|
+
for (const repository of topology.repositories) {
|
|
78
|
+
paths.add(repository.rootPath);
|
|
79
|
+
for (const alternate of repository.alternateWorktreeRoots)
|
|
80
|
+
paths.add(alternate);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
workspaceRoot: topology.scanRoot,
|
|
84
|
+
checkouts: [...paths]
|
|
85
|
+
.sort((left, right) => left.localeCompare(right))
|
|
86
|
+
.map(inspectLocalCheckout)
|
|
87
|
+
.filter((checkout) => checkout !== null),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export async function registerMachineIdentity(client, userId, machine, now = new Date().toISOString()) {
|
|
91
|
+
const { error } = await client.from('machines').upsert({
|
|
92
|
+
id: machine.id,
|
|
93
|
+
user_id: userId,
|
|
94
|
+
name: machine.name,
|
|
95
|
+
last_seen_at: now,
|
|
96
|
+
}, { onConflict: 'id' });
|
|
97
|
+
if (error)
|
|
98
|
+
throw new Error(`Could not register this machine: ${error.message}`);
|
|
99
|
+
}
|
|
100
|
+
export async function loadMachineProjects(client, userId, machineId) {
|
|
101
|
+
const { data, error } = await client
|
|
102
|
+
.from('machine_workspaces')
|
|
103
|
+
.select('project_id,project:projects!machine_workspaces_project_id_fkey(id,name)')
|
|
104
|
+
.eq('user_id', userId)
|
|
105
|
+
.eq('machine_id', machineId);
|
|
106
|
+
if (error)
|
|
107
|
+
throw new Error(`Could not load initialized projects: ${error.message}`);
|
|
108
|
+
const projects = new Map();
|
|
109
|
+
for (const row of (data ?? [])) {
|
|
110
|
+
const project = one(row.project);
|
|
111
|
+
if (project)
|
|
112
|
+
projects.set(project.id, project);
|
|
113
|
+
}
|
|
114
|
+
return [...projects.values()].sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
|
|
115
|
+
}
|
|
116
|
+
/** Legacy fallback only: multiple projects sharing one remote are deliberately
|
|
117
|
+
* returned as multiple candidates, never collapsed to an arbitrary project. */
|
|
118
|
+
export async function legacyCurrentProjectCandidates(client, cwd) {
|
|
119
|
+
const remote = currentRepoRemote(cwd);
|
|
120
|
+
if (!remote)
|
|
121
|
+
return [];
|
|
122
|
+
const { data, error } = await client
|
|
123
|
+
.from('projects')
|
|
124
|
+
.select('id,name')
|
|
125
|
+
.eq('git_remote_url', normalizeRemoteUrl(remote));
|
|
126
|
+
if (error)
|
|
127
|
+
throw new Error(`Could not resolve the current repository: ${error.message}`);
|
|
128
|
+
return (data ?? []).sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
|
|
129
|
+
}
|
|
130
|
+
function checkoutPlan(workspaceId, repositories, observation, existing, inspectCheckout, verifiedAt) {
|
|
131
|
+
const rows = new Map();
|
|
132
|
+
const availableRepositoryIds = new Set();
|
|
133
|
+
const record = (repositoryId, checkout, fallback) => {
|
|
134
|
+
const localPath = checkout?.localPath ?? fallback?.local_path;
|
|
135
|
+
if (!localPath)
|
|
136
|
+
return; // A missing repository without a known path is not fabricated.
|
|
137
|
+
const expectedRemote = [...repositories.entries()].find(([, id]) => id === repositoryId)?.[0];
|
|
138
|
+
if (!expectedRemote)
|
|
139
|
+
return;
|
|
140
|
+
const availability = checkout === null
|
|
141
|
+
? 'missing'
|
|
142
|
+
: checkout.remoteKey === expectedRemote
|
|
143
|
+
? 'available'
|
|
144
|
+
: 'mismatched';
|
|
145
|
+
if (availability === 'available')
|
|
146
|
+
availableRepositoryIds.add(repositoryId);
|
|
147
|
+
rows.set(`${repositoryId}\u0000${localPath}`, {
|
|
148
|
+
machine_workspace_id: workspaceId,
|
|
149
|
+
repository_id: repositoryId,
|
|
150
|
+
local_path: localPath,
|
|
151
|
+
availability,
|
|
152
|
+
verified_remote_key: checkout?.remoteKey ?? fallback?.verified_remote_key ?? null,
|
|
153
|
+
head_sha: checkout?.headSha ?? fallback?.head_sha ?? null,
|
|
154
|
+
is_worktree: checkout?.isWorktree ?? fallback?.is_worktree ?? false,
|
|
155
|
+
verified_at: verifiedAt,
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
for (const checkout of observation.checkouts) {
|
|
159
|
+
if (!checkout.remoteKey)
|
|
160
|
+
continue;
|
|
161
|
+
const repositoryId = repositories.get(checkout.remoteKey);
|
|
162
|
+
if (repositoryId)
|
|
163
|
+
record(repositoryId, checkout, null);
|
|
164
|
+
}
|
|
165
|
+
// A previous mapping is deterministic evidence for a now-missing checkout.
|
|
166
|
+
// Re-inspect it instead of assuming that an absent discovery result means
|
|
167
|
+
// the directory disappeared; it may simply sit outside today's scan root.
|
|
168
|
+
for (const prior of existing) {
|
|
169
|
+
if (![...repositories.values()].includes(prior.repository_id))
|
|
170
|
+
continue;
|
|
171
|
+
const key = `${prior.repository_id}\u0000${prior.local_path}`;
|
|
172
|
+
if (rows.has(key))
|
|
173
|
+
continue;
|
|
174
|
+
record(prior.repository_id, inspectCheckout(prior.local_path), prior);
|
|
175
|
+
}
|
|
176
|
+
return { rows: [...rows.values()], availableRepositoryIds };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Registers the stable machine, this project's private workspace root, and
|
|
180
|
+
* every checkout that can be proven from Git or a prior local mapping.
|
|
181
|
+
*/
|
|
182
|
+
export async function registerMachineWorkspace(client, userId, projectId, machine, cwd, deps = {}) {
|
|
183
|
+
const now = (deps.now ?? (() => new Date().toISOString()))();
|
|
184
|
+
const observation = (deps.observeWorkspace ?? observeLocalWorkspace)(cwd);
|
|
185
|
+
await registerMachineIdentity(client, userId, machine, now);
|
|
186
|
+
const { data: scopeData, error: scopeError } = await client
|
|
187
|
+
.from('project_repository_scopes')
|
|
188
|
+
.select('required,scope:repository_scopes!project_repository_scopes_repository_scope_id_fkey(' +
|
|
189
|
+
'repository_id,repository:repositories!repository_scopes_repository_id_fkey(id,remote_key))')
|
|
190
|
+
.eq('project_id', projectId)
|
|
191
|
+
.eq('active', true);
|
|
192
|
+
if (scopeError)
|
|
193
|
+
throw new Error(`Could not load project repositories: ${scopeError.message}`);
|
|
194
|
+
const repositories = new Map();
|
|
195
|
+
const requiredRepositoryIds = new Set();
|
|
196
|
+
for (const row of (scopeData ?? [])) {
|
|
197
|
+
const scope = one(row.scope);
|
|
198
|
+
const repository = scope ? one(scope.repository) : null;
|
|
199
|
+
if (repository) {
|
|
200
|
+
repositories.set(repository.remote_key, repository.id);
|
|
201
|
+
if (row.required)
|
|
202
|
+
requiredRepositoryIds.add(repository.id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (repositories.size === 0) {
|
|
206
|
+
throw new Error('This project has no active repository topology. Run `ctrl-spc init` again to repair it.');
|
|
207
|
+
}
|
|
208
|
+
const { data: priorWorkspaceData, error: priorWorkspaceError } = await client
|
|
209
|
+
.from('machine_workspaces')
|
|
210
|
+
.select('id,workspace_root')
|
|
211
|
+
.eq('user_id', userId)
|
|
212
|
+
.eq('machine_id', machine.id)
|
|
213
|
+
.eq('project_id', projectId)
|
|
214
|
+
.maybeSingle();
|
|
215
|
+
if (priorWorkspaceError) {
|
|
216
|
+
throw new Error(`Could not load this project workspace: ${priorWorkspaceError.message}`);
|
|
217
|
+
}
|
|
218
|
+
const priorWorkspace = priorWorkspaceData;
|
|
219
|
+
// Running later from one child repository must not shrink a previously
|
|
220
|
+
// registered combined workspace back down to that child. Likewise, a
|
|
221
|
+
// broker launched from project A must not move project B's workspace root.
|
|
222
|
+
const observationMatchesProject = observation.checkouts.some((checkout) => checkout.remoteKey !== null && repositories.has(checkout.remoteKey));
|
|
223
|
+
const workspaceRoot = priorWorkspace && (isWithin(priorWorkspace.workspace_root, observation.workspaceRoot) || !observationMatchesProject)
|
|
224
|
+
? priorWorkspace.workspace_root
|
|
225
|
+
: observation.workspaceRoot;
|
|
226
|
+
const { data: workspaceData, error: workspaceError } = await client
|
|
227
|
+
.from('machine_workspaces')
|
|
228
|
+
.upsert({
|
|
229
|
+
user_id: userId,
|
|
230
|
+
machine_id: machine.id,
|
|
231
|
+
project_id: projectId,
|
|
232
|
+
workspace_root: workspaceRoot,
|
|
233
|
+
verified_at: now,
|
|
234
|
+
}, { onConflict: 'user_id,machine_id,project_id' })
|
|
235
|
+
.select('id')
|
|
236
|
+
.single();
|
|
237
|
+
if (workspaceError || !workspaceData) {
|
|
238
|
+
throw new Error(`Could not register this project workspace: ${workspaceError?.message ?? 'no workspace returned'}`);
|
|
239
|
+
}
|
|
240
|
+
const workspaceId = workspaceData.id;
|
|
241
|
+
const { data: existingData, error: existingError } = await client
|
|
242
|
+
.from('repository_checkouts')
|
|
243
|
+
.select('repository_id,local_path,verified_remote_key,head_sha,is_worktree')
|
|
244
|
+
.eq('machine_workspace_id', workspaceId);
|
|
245
|
+
if (existingError)
|
|
246
|
+
throw new Error(`Could not load repository checkouts: ${existingError.message}`);
|
|
247
|
+
const plan = checkoutPlan(workspaceId, repositories, observation, (existingData ?? []), deps.inspectCheckout ?? inspectLocalCheckout, now);
|
|
248
|
+
for (const row of plan.rows) {
|
|
249
|
+
const { error } = await client.from('repository_checkouts').upsert(row, {
|
|
250
|
+
onConflict: 'machine_workspace_id,repository_id,local_path',
|
|
251
|
+
});
|
|
252
|
+
if (error)
|
|
253
|
+
throw new Error(`Could not register repository checkout ${row.local_path}: ${error.message}`);
|
|
254
|
+
}
|
|
255
|
+
// Preserve a combine/separate relink marker until the entire reconciliation
|
|
256
|
+
// succeeds and every repository used by a required scope is available.
|
|
257
|
+
// Optional-only repositories are still inspected above, but do not make a
|
|
258
|
+
// workspace permanently impossible to acknowledge.
|
|
259
|
+
const requiredRepositoriesAvailable = [...requiredRepositoryIds]
|
|
260
|
+
.every((repositoryId) => plan.availableRepositoryIds.has(repositoryId));
|
|
261
|
+
if (requiredRepositoriesAvailable) {
|
|
262
|
+
const { error } = await client
|
|
263
|
+
.from('machine_workspaces')
|
|
264
|
+
.update({ needs_relink: false })
|
|
265
|
+
.eq('id', workspaceId);
|
|
266
|
+
if (error)
|
|
267
|
+
throw new Error(`Could not confirm this project workspace relink: ${error.message}`);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
workspaceId,
|
|
271
|
+
availableRepositories: plan.availableRepositoryIds.size,
|
|
272
|
+
missingRepositories: repositories.size - plan.availableRepositoryIds.size,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Turns a raw MCP-server startup rejection into the error `run()` throws.
|
|
277
|
+
* `EADDRINUSE` specifically (a second `ctrl-spc` instance, or anything else
|
|
278
|
+
* bound to the port) gets a message a human can act on instead of a raw
|
|
279
|
+
* Node stack; anything else passes through unchanged.
|
|
280
|
+
*/
|
|
281
|
+
export function formatMcpStartupError(err, port) {
|
|
282
|
+
if (err?.code === 'EADDRINUSE') {
|
|
283
|
+
return new Error(`port ${port} in use — is another ctrl-spc running? (override with CTRL_SPC_PORT)`);
|
|
284
|
+
}
|
|
285
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
286
|
+
}
|
|
287
|
+
const runCommand = (command, args) => execFileSync(command, args, {
|
|
288
|
+
encoding: 'utf8',
|
|
289
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
290
|
+
});
|
|
291
|
+
function claudeScope(details) {
|
|
292
|
+
const scope = details.match(/Scope:\s*(User|Local|Project)/i)?.[1]?.toLowerCase();
|
|
293
|
+
return scope === 'user' || scope === 'local' || scope === 'project' ? scope : undefined;
|
|
294
|
+
}
|
|
295
|
+
function failedCommandOutput(err) {
|
|
296
|
+
const stdout = err?.stdout;
|
|
297
|
+
return typeof stdout === 'string' ? stdout : stdout?.toString('utf8') ?? '';
|
|
298
|
+
}
|
|
299
|
+
function syncClaudeMcp(command, url, runner) {
|
|
300
|
+
let details = '';
|
|
301
|
+
try {
|
|
302
|
+
details = runner(command, ['mcp', 'get', 'ctrl-spc']);
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
// Claude's `mcp get` health-checks HTTP servers and can exit non-zero
|
|
306
|
+
// while ctrl-spc is still starting, even though it prints the saved URL
|
|
307
|
+
// and scope. Preserve that stdout so a correct registration is not
|
|
308
|
+
// mistaken for a missing one.
|
|
309
|
+
details = failedCommandOutput(err);
|
|
310
|
+
}
|
|
311
|
+
const scope = claudeScope(details);
|
|
312
|
+
if (details.includes(`URL: ${url}`) && scope === 'user') {
|
|
313
|
+
console.log('Claude MCP: ctrl-spc registered (user scope).');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (details) {
|
|
317
|
+
const removeArgs = ['mcp', 'remove', 'ctrl-spc'];
|
|
318
|
+
if (scope)
|
|
319
|
+
removeArgs.push('-s', scope);
|
|
320
|
+
runner(command, removeArgs);
|
|
321
|
+
}
|
|
322
|
+
runner(command, ['mcp', 'add', '--transport', 'http', '--scope', 'user', 'ctrl-spc', url]);
|
|
323
|
+
console.log('Claude MCP: ctrl-spc registered (user scope).');
|
|
324
|
+
}
|
|
325
|
+
/** Codex user config, respecting the same CODEX_HOME override as the Codex CLI. */
|
|
326
|
+
export function codexConfigPath() {
|
|
327
|
+
return join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml');
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Keeps normal non-interactive Codex runs from cancelling ctrl-spc MCP calls.
|
|
331
|
+
* The Codex CLI owns creation of the canonical MCP block; this function only
|
|
332
|
+
* repairs its tool approval mode while preserving every unrelated TOML byte.
|
|
333
|
+
*/
|
|
334
|
+
export function ensureCodexMcpApproval(path = codexConfigPath()) {
|
|
335
|
+
if (!existsSync(path))
|
|
336
|
+
throw new Error(`Codex config not found after MCP registration: ${path}`);
|
|
337
|
+
const content = readFileSync(path, 'utf8');
|
|
338
|
+
const header = /^[ \t]*\[mcp_servers\.(?:ctrl-spc|"ctrl-spc")\][ \t]*(?:#.*)?$/m.exec(content);
|
|
339
|
+
if (!header)
|
|
340
|
+
throw new Error(`Codex MCP registration did not create [mcp_servers.ctrl-spc] in ${path}`);
|
|
341
|
+
const headerEnd = header.index + header[0].length;
|
|
342
|
+
const afterHeader = content.slice(headerEnd);
|
|
343
|
+
const nextHeader = /^[ \t]*\[\[?[^\]\r\n]+\]\]?[ \t]*(?:#.*)?$/m.exec(afterHeader);
|
|
344
|
+
const blockEnd = headerEnd + (nextHeader?.index ?? afterHeader.length);
|
|
345
|
+
const block = content.slice(headerEnd, blockEnd);
|
|
346
|
+
const approval = /^([ \t]*default_tools_approval_mode[ \t]*=[ \t]*)(?:"([^"\r\n]*)"|'([^'\r\n]*)'|([^#\r\n]*?))([ \t]*(?:#.*)?)$/m.exec(block);
|
|
347
|
+
// Only a TOML string is valid for this schema field. Repair an unquoted
|
|
348
|
+
// bare value even if its text happens to say `approve`.
|
|
349
|
+
if (approval && (approval[2] === 'approve' || approval[3] === 'approve'))
|
|
350
|
+
return;
|
|
351
|
+
let updated;
|
|
352
|
+
if (approval) {
|
|
353
|
+
const valueStart = headerEnd + approval.index;
|
|
354
|
+
const valueEnd = valueStart + approval[0].length;
|
|
355
|
+
updated = content.slice(0, valueStart) + `${approval[1]}"approve"${approval[5]}` + content.slice(valueEnd);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
const newline = content.includes('\r\n') ? '\r\n' : '\n';
|
|
359
|
+
const leadingNewline = blockEnd > 0 && content[blockEnd - 1] !== '\n' ? newline : '';
|
|
360
|
+
updated = content.slice(0, blockEnd) + `${leadingNewline}default_tools_approval_mode = "approve"${newline}` + content.slice(blockEnd);
|
|
361
|
+
}
|
|
362
|
+
const dir = dirname(path);
|
|
363
|
+
mkdirSync(dir, { recursive: true });
|
|
364
|
+
const mode = statSync(path).mode & 0o777;
|
|
365
|
+
const tempPath = join(dir, `.config.toml.${process.pid}.${randomUUID()}.tmp`);
|
|
366
|
+
try {
|
|
367
|
+
writeFileSync(tempPath, updated, { encoding: 'utf8', mode });
|
|
368
|
+
chmodSync(tempPath, mode);
|
|
369
|
+
renameSync(tempPath, path);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
rmSync(tempPath, { force: true });
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function syncCodexMcp(command, url, runner, repairApproval) {
|
|
377
|
+
const raw = runner(command, ['mcp', 'list', '--json']);
|
|
378
|
+
const entries = JSON.parse(raw);
|
|
379
|
+
const managed = entries.filter((entry) => entry.name === 'ctrl-spc' || entry.name === 'ctrl_spc');
|
|
380
|
+
const canonical = managed.find((entry) => entry.name === 'ctrl-spc');
|
|
381
|
+
if (managed.length === 1 &&
|
|
382
|
+
canonical?.enabled === true &&
|
|
383
|
+
!canonical.disabled_reason &&
|
|
384
|
+
canonical.transport?.type === 'streamable_http' &&
|
|
385
|
+
canonical.transport.url === url) {
|
|
386
|
+
repairApproval();
|
|
387
|
+
console.log('Codex MCP: ctrl-spc registered.');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
for (const entry of managed) {
|
|
391
|
+
if (entry.name)
|
|
392
|
+
runner(command, ['mcp', 'remove', entry.name]);
|
|
393
|
+
}
|
|
394
|
+
runner(command, ['mcp', 'add', 'ctrl-spc', '--url', url]);
|
|
395
|
+
repairApproval();
|
|
396
|
+
console.log('Codex MCP: ctrl-spc registered.');
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Makes the detected-agent list truthful: only agents whose MCP config is
|
|
400
|
+
* confirmed at the current endpoint are published as available presence.
|
|
401
|
+
*/
|
|
402
|
+
export function registerAgentMcps(agents, url, runner = runCommand, commandResolver = resolveAgentCommand, repairCodexApproval = () => ensureCodexMcpApproval()) {
|
|
403
|
+
if (process.env.CTRL_SPC_SKIP_MCP_REGISTER === '1') {
|
|
404
|
+
console.log('Agent MCP registration skipped (CTRL_SPC_SKIP_MCP_REGISTER=1).');
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
const registered = [];
|
|
408
|
+
for (const agent of agents) {
|
|
409
|
+
const command = commandResolver(agent);
|
|
410
|
+
if (!command)
|
|
411
|
+
continue;
|
|
412
|
+
try {
|
|
413
|
+
if (agent === 'claude')
|
|
414
|
+
syncClaudeMcp(command, url, runner);
|
|
415
|
+
else
|
|
416
|
+
syncCodexMcp(command, url, runner, repairCodexApproval);
|
|
417
|
+
registered.push(agent);
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
console.warn(`Warning: could not register ctrl-spc with ${agent}: ${err.message}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return registered;
|
|
424
|
+
}
|
|
425
|
+
/** Always refresh agent skills and MCP registrations, even when this command
|
|
426
|
+
* will hand control back to an already-running same-machine broker. */
|
|
427
|
+
export async function prepareBrokerAgents(machineId, deps = {}) {
|
|
428
|
+
const brokerAlreadyRunning = await (deps.status ?? ((id) => existingBrokerStatus(id)))(machineId) === 'same_machine';
|
|
429
|
+
const agents = (deps.detect ?? detectAgents)();
|
|
430
|
+
console.log(`Agents detected: ${agents.length > 0 ? agents.join(', ') : 'none'}`);
|
|
431
|
+
if (agents.length === 0) {
|
|
432
|
+
throw new Error('No supported coding agent found. Install Claude Code or Codex, then run `ctrl-spc` again.');
|
|
433
|
+
}
|
|
434
|
+
const url = mcpUrl();
|
|
435
|
+
const install = deps.install ?? installSkills;
|
|
436
|
+
install(agents, url);
|
|
437
|
+
if (agents.includes('claude'))
|
|
438
|
+
console.log(`Skill installed: ${claudeSkillPath()}`);
|
|
439
|
+
if (agents.includes('codex'))
|
|
440
|
+
console.log(`Skill installed: ${codexSkillPath()}`);
|
|
441
|
+
const registeredAgents = (deps.register ?? registerAgentMcps)(agents, url);
|
|
442
|
+
if (registeredAgents.length === 0) {
|
|
443
|
+
throw new Error('No detected coding agent could be registered with ctrl-spc. Fix the warnings above, then run `ctrl-spc` again.');
|
|
444
|
+
}
|
|
445
|
+
return { registeredAgents, brokerAlreadyRunning };
|
|
446
|
+
}
|
|
447
|
+
async function loadProfileName(client, userId) {
|
|
448
|
+
const { data, error } = await client.from('profiles').select('name').eq('id', userId).single();
|
|
449
|
+
if (error || !data) {
|
|
450
|
+
throw new Error(`Could not load your profile: ${error?.message ?? 'no profile returned'}`);
|
|
451
|
+
}
|
|
452
|
+
return data.name;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Keeps one Presence channel per current machine workspace while retaining a
|
|
456
|
+
* single MCP listener. Reconciliation is serialized so a slow network poll
|
|
457
|
+
* can never create duplicate channels for the same project.
|
|
458
|
+
*/
|
|
459
|
+
export function createProjectPresenceReconciler(client, userId, profileName, machine, agents, deps = {}) {
|
|
460
|
+
const controllers = new Map();
|
|
461
|
+
const loadProjects = deps.loadProjects ?? loadMachineProjects;
|
|
462
|
+
const openPresence = deps.openPresence ?? startPresence;
|
|
463
|
+
const intervalMs = deps.intervalMs ?? 30_000;
|
|
464
|
+
let timer;
|
|
465
|
+
let chain = Promise.resolve();
|
|
466
|
+
const runReconcile = async () => {
|
|
467
|
+
const projects = await loadProjects(client, userId, machine.id);
|
|
468
|
+
const currentIds = new Set(projects.map((project) => project.id));
|
|
469
|
+
for (const project of projects) {
|
|
470
|
+
if (controllers.has(project.id))
|
|
471
|
+
continue;
|
|
472
|
+
controllers.set(project.id, openPresence(client, {
|
|
473
|
+
userId,
|
|
474
|
+
name: profileName,
|
|
475
|
+
machineId: machine.id,
|
|
476
|
+
machineName: machine.name,
|
|
477
|
+
agents,
|
|
478
|
+
projectId: project.id,
|
|
479
|
+
status: 'available',
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
482
|
+
for (const [projectId, controller] of [...controllers]) {
|
|
483
|
+
if (currentIds.has(projectId))
|
|
484
|
+
continue;
|
|
485
|
+
controllers.delete(projectId);
|
|
486
|
+
await controller.stop();
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
return {
|
|
490
|
+
reconcile() {
|
|
491
|
+
// Recover the queue after a transient failed poll; the caller still
|
|
492
|
+
// receives this run's rejection, but later intervals keep reconciling.
|
|
493
|
+
chain = chain.catch(() => { }).then(runReconcile);
|
|
494
|
+
return chain;
|
|
495
|
+
},
|
|
496
|
+
start() {
|
|
497
|
+
if (timer)
|
|
498
|
+
return;
|
|
499
|
+
timer = setInterval(() => {
|
|
500
|
+
void this.reconcile().catch((err) => {
|
|
501
|
+
console.warn(`Warning: could not refresh project presence: ${err.message}`);
|
|
502
|
+
});
|
|
503
|
+
}, intervalMs);
|
|
504
|
+
timer.unref();
|
|
505
|
+
},
|
|
506
|
+
projectIds: () => [...controllers.keys()].sort(),
|
|
507
|
+
setWorking: () => { },
|
|
508
|
+
setAvailable: () => { },
|
|
509
|
+
async stop() {
|
|
510
|
+
if (timer)
|
|
511
|
+
clearInterval(timer);
|
|
512
|
+
timer = undefined;
|
|
513
|
+
await chain.catch(() => { });
|
|
514
|
+
const active = [...controllers.values()];
|
|
515
|
+
controllers.clear();
|
|
516
|
+
await Promise.all(active.map((controller) => controller.stop()));
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Installs a SIGINT/SIGTERM handler that closes the MCP server (so no new
|
|
522
|
+
* tool call can flip presence back to "working" mid-shutdown) and only then
|
|
523
|
+
* untracks presence, before exiting.
|
|
524
|
+
*/
|
|
525
|
+
function installShutdownHandler(presence, mcpServer) {
|
|
526
|
+
let shuttingDown = false;
|
|
527
|
+
const shutdown = () => {
|
|
528
|
+
if (shuttingDown)
|
|
529
|
+
return;
|
|
530
|
+
shuttingDown = true;
|
|
531
|
+
console.log('\nShutting down — closing MCP server, untracking presence...');
|
|
532
|
+
mcpServer
|
|
533
|
+
.close()
|
|
534
|
+
.catch(() => { })
|
|
535
|
+
.then(() => presence.stop())
|
|
536
|
+
.catch(() => { })
|
|
537
|
+
.then(() => process.exit(0));
|
|
538
|
+
};
|
|
539
|
+
process.on('SIGINT', shutdown);
|
|
540
|
+
process.on('SIGTERM', shutdown);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* `ctrl-spc` (no args): one long-running broker per machine. It serves every
|
|
544
|
+
* initialized machine workspace through one listener and any number of MCP
|
|
545
|
+
* sessions; begin_work binds each session to the pasted task's project.
|
|
546
|
+
*/
|
|
547
|
+
export async function run() {
|
|
548
|
+
const cwd = process.cwd();
|
|
549
|
+
const client = await getClient();
|
|
550
|
+
const { data: userData, error: userError } = await client.auth.getUser();
|
|
551
|
+
if (userError || !userData.user) {
|
|
552
|
+
console.error(`Could not resolve the signed-in user: ${userError?.message ?? 'no user returned'}`);
|
|
553
|
+
process.exitCode = 1;
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const userId = userData.user.id;
|
|
557
|
+
const profileName = await loadProfileName(client, userId);
|
|
558
|
+
const machine = getMachineIdentity();
|
|
559
|
+
await registerMachineIdentity(client, userId, machine);
|
|
560
|
+
let localObservation = null;
|
|
561
|
+
const observeOnce = () => {
|
|
562
|
+
localObservation ??= observeLocalWorkspace(cwd);
|
|
563
|
+
return localObservation;
|
|
564
|
+
};
|
|
565
|
+
let projects = await loadMachineProjects(client, userId, machine.id);
|
|
566
|
+
const legacyCandidates = await legacyCurrentProjectCandidates(client, cwd);
|
|
567
|
+
if (legacyCandidates.length === 1 && !projects.some((project) => project.id === legacyCandidates[0].id)) {
|
|
568
|
+
await registerMachineWorkspace(client, userId, legacyCandidates[0].id, machine, cwd, {
|
|
569
|
+
observeWorkspace: observeOnce,
|
|
570
|
+
});
|
|
571
|
+
projects = await loadMachineProjects(client, userId, machine.id);
|
|
572
|
+
}
|
|
573
|
+
else if (legacyCandidates.length > 1) {
|
|
574
|
+
console.log(`Current repository belongs to ${legacyCandidates.length} projects; the broker kept their existing explicit workspace links.`);
|
|
575
|
+
}
|
|
576
|
+
let availableRepositories = 0;
|
|
577
|
+
let missingRepositories = 0;
|
|
578
|
+
for (const project of projects) {
|
|
579
|
+
const workspace = await registerMachineWorkspace(client, userId, project.id, machine, cwd, {
|
|
580
|
+
observeWorkspace: observeOnce,
|
|
581
|
+
});
|
|
582
|
+
availableRepositories += workspace.availableRepositories;
|
|
583
|
+
missingRepositories += workspace.missingRepositories;
|
|
584
|
+
}
|
|
585
|
+
console.log(`Broker projects: ${projects.length}; repositories: ${availableRepositories} available` +
|
|
586
|
+
(missingRepositories > 0 ? `, ${missingRepositories} missing` : ''));
|
|
587
|
+
const url = mcpUrl();
|
|
588
|
+
const prepared = await prepareBrokerAgents(machine.id);
|
|
589
|
+
const registeredAgents = prepared.registeredAgents;
|
|
590
|
+
if (prepared.brokerAlreadyRunning) {
|
|
591
|
+
console.log(`Broker already running: ${url}`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
// Project channels reconcile independently from the one MCP listener, so
|
|
595
|
+
// `ctrl-spc init` can add a project without restarting this broker.
|
|
596
|
+
const presence = createProjectPresenceReconciler(client, userId, profileName, machine, registeredAgents);
|
|
597
|
+
await presence.reconcile();
|
|
598
|
+
presence.start();
|
|
599
|
+
let mcpServer;
|
|
600
|
+
try {
|
|
601
|
+
mcpServer = await startMcpServer({
|
|
602
|
+
client,
|
|
603
|
+
userId,
|
|
604
|
+
machineId: machine.id,
|
|
605
|
+
presence,
|
|
606
|
+
port: MCP_PORT,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
// Presence is already tracked as "available" at this point — untrack it
|
|
611
|
+
// before propagating so a failed startup doesn't leave a phantom
|
|
612
|
+
// "available" agent on the board.
|
|
613
|
+
await presence.stop().catch(() => { });
|
|
614
|
+
throw formatMcpStartupError(err, MCP_PORT);
|
|
615
|
+
}
|
|
616
|
+
console.log(`MCP: ${url} (tools: list_tasks, get_task, begin_work, heartbeat_work, reserve_work_paths, ` +
|
|
617
|
+
'release_work_paths, transfer_coordination, acknowledge_unavailable_checkout, record_context_exploration, ' +
|
|
618
|
+
'ask_question, end_work, create_task, update_task, create_artifact, add_comment)');
|
|
619
|
+
console.log(`Projects: ${projects.length > 0 ? projects.map((project) => project.name).join(', ') : 'none initialized on this machine'}`);
|
|
620
|
+
console.log('Presence: available');
|
|
621
|
+
installShutdownHandler(presence, mcpServer);
|
|
622
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { homedir, hostname } from 'node:os';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const SESSION_FILE_NAME = 'session.json';
|
|
6
|
+
const MACHINE_FILE_NAME = 'machine.json';
|
|
7
|
+
/** Config directory: `CTRL_SPC_CONFIG_DIR` env override, else `~/.config/ctrl-spc`. */
|
|
8
|
+
export function configDir() {
|
|
9
|
+
return process.env.CTRL_SPC_CONFIG_DIR || join(homedir(), '.config', 'ctrl-spc');
|
|
10
|
+
}
|
|
11
|
+
function sessionFilePath() {
|
|
12
|
+
return join(configDir(), SESSION_FILE_NAME);
|
|
13
|
+
}
|
|
14
|
+
function machineFilePath() {
|
|
15
|
+
return join(configDir(), MACHINE_FILE_NAME);
|
|
16
|
+
}
|
|
17
|
+
/** Stable per-machine identity used as the Realtime presence key. */
|
|
18
|
+
export function getMachineIdentity() {
|
|
19
|
+
const path = machineFilePath();
|
|
20
|
+
if (existsSync(path)) {
|
|
21
|
+
try {
|
|
22
|
+
const stored = JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
+
if (typeof stored.id === 'string' && stored.id && typeof stored.name === 'string' && stored.name) {
|
|
24
|
+
return { id: stored.id, name: stored.name };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch { /* replace a corrupt identity below */ }
|
|
28
|
+
}
|
|
29
|
+
const identity = {
|
|
30
|
+
id: randomUUID(),
|
|
31
|
+
name: hostname().replace(/\.local$/i, '') || 'This machine',
|
|
32
|
+
};
|
|
33
|
+
mkdirSync(configDir(), { recursive: true });
|
|
34
|
+
writeFileSync(path, JSON.stringify(identity, null, 2), { mode: 0o600 });
|
|
35
|
+
chmodSync(path, 0o600);
|
|
36
|
+
return identity;
|
|
37
|
+
}
|
|
38
|
+
/** Reads the stored session, or `null` if none has been saved yet or the file is corrupted. */
|
|
39
|
+
export function readSession() {
|
|
40
|
+
const path = sessionFilePath();
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
return null;
|
|
43
|
+
const raw = readFileSync(path, 'utf8');
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(raw);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Deletes the stored session. Returns whether one existed. */
|
|
52
|
+
export function clearSession() {
|
|
53
|
+
const path = sessionFilePath();
|
|
54
|
+
if (!existsSync(path))
|
|
55
|
+
return false;
|
|
56
|
+
rmSync(path);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/** Persists the session to disk, creating the config dir if needed, mode 0600. */
|
|
60
|
+
export function writeSession(session) {
|
|
61
|
+
const dir = configDir();
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
const path = sessionFilePath();
|
|
64
|
+
writeFileSync(path, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
65
|
+
// writeFileSync's `mode` only applies when the file is created; chmod
|
|
66
|
+
// explicitly so re-writing an existing session.json still ends up 0600.
|
|
67
|
+
chmodSync(path, 0o600);
|
|
68
|
+
}
|