@ctrl-spc/cs 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/agents.js +6 -0
- package/dist/companion-ui.js +205 -3
- package/dist/companion.js +23 -1
- package/dist/config.js +14 -1
- package/dist/env.js +10 -0
- package/dist/mcp.js +2332 -0
- package/dist/package-version.js +10 -0
- package/dist/presence-heartbeat.js +13 -0
- package/dist/presence.js +70 -8
- package/dist/supabase.js +4 -1
- package/package.json +9 -3
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @ctrl-spc/cs
|
|
2
|
+
|
|
3
|
+
The `cs` command keeps your computer online for **CTRL+SPC** so your coding
|
|
4
|
+
agents can be reached, and opens the **Companion** — a small local app for
|
|
5
|
+
signing in and attaching your codebases to your projects.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm i -g @ctrl-spc/cs && cs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That's it. The `&& cs` opens your browser straight to sign-in — you barely touch
|
|
14
|
+
the terminal:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Welcome to CTRL+SPC — opening your browser to sign in…
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
In the browser you'll:
|
|
21
|
+
|
|
22
|
+
1. **Sign in** (or sign up on the web).
|
|
23
|
+
2. **Attach a codebase** — point a project at a folder on this computer that has
|
|
24
|
+
a git remote.
|
|
25
|
+
3. **Open the web app** — create work items and hand them to an agent.
|
|
26
|
+
|
|
27
|
+
Your folder paths stay on this computer; only path-free identifiers (a git
|
|
28
|
+
remote URL) are shared.
|
|
29
|
+
|
|
30
|
+
## Stay online
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
cs autostart on
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Comes online automatically at login, with no window. Turn it off with
|
|
37
|
+
`cs autostart off`.
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
cs Open the Companion app (the front door)
|
|
43
|
+
cs open Open the Companion app in your browser
|
|
44
|
+
cs login Sign in from the terminal and link this computer
|
|
45
|
+
cs start Come online now, no window (used by auto-start)
|
|
46
|
+
cs status Show sign-in state, computer, and detected agents
|
|
47
|
+
cs autostart on Come online automatically at login
|
|
48
|
+
cs autostart off Stop coming online at login
|
|
49
|
+
cs logout Sign this computer out
|
|
50
|
+
cs help Show this help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- Node.js >= 22.
|
|
56
|
+
|
|
57
|
+
Not sure what a piece does? Open the Companion (`cs`) and click **How it works**.
|
package/dist/agents.js
CHANGED
|
@@ -37,3 +37,9 @@ function resolve(agent) {
|
|
|
37
37
|
export function detectAgents() {
|
|
38
38
|
return AGENTS.filter((a) => resolve(a) !== null);
|
|
39
39
|
}
|
|
40
|
+
/** Absolute path to an agent's binary (PATH first, then well-known fallbacks),
|
|
41
|
+
* or null when it isn't installed. Used to invoke `claude mcp add` at the same
|
|
42
|
+
* binary `detectAgents()` found. */
|
|
43
|
+
export function agentPath(agent) {
|
|
44
|
+
return resolve(agent);
|
|
45
|
+
}
|
package/dist/companion-ui.js
CHANGED
|
@@ -220,6 +220,10 @@ code, .mono { font-family: var(--font-mono); }
|
|
|
220
220
|
.codebase-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
|
221
221
|
.codebases-empty { font-size: var(--text-sm); color: var(--faint); }
|
|
222
222
|
.form-note { color: var(--ok); font-size: var(--text-sm); min-height: 1.1em; }
|
|
223
|
+
.empty-cta { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); text-align: center; padding: var(--sp-6) var(--sp-4); border: 1px dashed var(--border-strong); border-radius: var(--r-md); }
|
|
224
|
+
.empty-cta.is-compact { padding: var(--sp-5) var(--sp-4); gap: var(--sp-2); }
|
|
225
|
+
.empty-cta-title { font-weight: 600; }
|
|
226
|
+
.empty-cta .muted { font-size: var(--text-sm); max-width: 46ch; }
|
|
223
227
|
.pill {
|
|
224
228
|
display: inline-flex; align-items: center; flex-shrink: 0; white-space: nowrap;
|
|
225
229
|
font-size: var(--text-xs); font-weight: 500; padding: 2px 10px; border-radius: 20px;
|
|
@@ -227,6 +231,19 @@ code, .mono { font-family: var(--font-mono); }
|
|
|
227
231
|
}
|
|
228
232
|
.pill-ok { color: var(--ok); border-color: var(--ok); }
|
|
229
233
|
.locate-match { min-height: 1.2em; }
|
|
234
|
+
|
|
235
|
+
/* --- agent tools badge (feature 05: passive, read-only) --- */
|
|
236
|
+
.agent-tools {
|
|
237
|
+
display: flex; align-items: center; gap: var(--sp-3);
|
|
238
|
+
border: 1px solid var(--border); border-radius: var(--r-md);
|
|
239
|
+
padding: var(--sp-3) var(--sp-4); background: var(--surface);
|
|
240
|
+
}
|
|
241
|
+
.agent-tools-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; background: var(--faint); }
|
|
242
|
+
.agent-tools-dot.is-on { background: var(--ok); }
|
|
243
|
+
/* Amber "connecting…" while async registration is in flight (no amber token). */
|
|
244
|
+
.agent-tools-dot.is-wait { background: #e0a020; }
|
|
245
|
+
.agent-tools-title { font-weight: 600; }
|
|
246
|
+
.agent-tools-sub { font-size: var(--text-xs); color: var(--muted); margin-top: 2px; }
|
|
230
247
|
</style>
|
|
231
248
|
</head>
|
|
232
249
|
<body>
|
|
@@ -274,7 +291,82 @@ async function boot() {
|
|
|
274
291
|
else renderSignIn(r.data || {});
|
|
275
292
|
}
|
|
276
293
|
|
|
294
|
+
/** The passive "Agent tools" badge (feature 05). Read-only: no button, no
|
|
295
|
+
* action. Reflects the lifecycle state the server computed in /api/session —
|
|
296
|
+
* connecting (amber, async registration in flight) / connected / couldn't-
|
|
297
|
+
* connect, plus the signed-out and no-agent off-states. */
|
|
298
|
+
function agentToolsBadge(state) {
|
|
299
|
+
state = state || { connected: false, reason: 'signed-out', agent: '', server: 'ctrl-spc' };
|
|
300
|
+
const reason = state.reason;
|
|
301
|
+
const box = el('div', 'agent-tools');
|
|
302
|
+
let dotCls = 'agent-tools-dot';
|
|
303
|
+
if (reason === 'connected') dotCls += ' is-on';
|
|
304
|
+
else if (reason === 'connecting') dotCls += ' is-wait';
|
|
305
|
+
const dot = el('span', dotCls);
|
|
306
|
+
const body = el('div');
|
|
307
|
+
let title, sub;
|
|
308
|
+
if (reason === 'connected') {
|
|
309
|
+
title = 'Agent tools: connected \\u2713';
|
|
310
|
+
sub = (state.agent || 'Claude') + ' \\u00b7 ' + (state.server || 'ctrl-spc');
|
|
311
|
+
} else if (reason === 'connecting') {
|
|
312
|
+
title = 'Agent tools: connecting\\u2026';
|
|
313
|
+
sub = state.agent || '';
|
|
314
|
+
} else if (reason === 'failed') {
|
|
315
|
+
title = 'Agent tools: not connected';
|
|
316
|
+
sub = "couldn't connect the tools \\u2014 will retry";
|
|
317
|
+
} else if (reason === 'no-agent') {
|
|
318
|
+
title = 'Agent tools: not connected';
|
|
319
|
+
sub = 'No supported agent (Claude or Codex) found on this machine';
|
|
320
|
+
} else {
|
|
321
|
+
title = 'Agent tools: not connected';
|
|
322
|
+
sub = 'sign in to connect';
|
|
323
|
+
}
|
|
324
|
+
body.append(el('div', 'agent-tools-title', title));
|
|
325
|
+
body.append(el('div', 'agent-tools-sub mono', sub));
|
|
326
|
+
box.append(dot, body);
|
|
327
|
+
return box;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The badge must flip connecting\\u2192connected on its own (Claude registers in
|
|
331
|
+
// ~7s) with no page reload. A light 2s poll of /api/session swaps ONLY the badge
|
|
332
|
+
// element in place — never a full home re-render, which would disrupt the
|
|
333
|
+
// codebases list, open dialogs, and focus. Guards against overlapping fetches and
|
|
334
|
+
// stops itself once the badge leaves the DOM (home torn down).
|
|
335
|
+
let badgePollTimer = null;
|
|
336
|
+
let badgePolling = false;
|
|
337
|
+
|
|
338
|
+
function stopBadgePoll() {
|
|
339
|
+
if (badgePollTimer) { clearInterval(badgePollTimer); badgePollTimer = null; }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function pollBadgeOnce() {
|
|
343
|
+
if (badgePolling) return;
|
|
344
|
+
const badge = document.getElementById('agent-tools');
|
|
345
|
+
if (!badge) { stopBadgePoll(); return; }
|
|
346
|
+
badgePolling = true;
|
|
347
|
+
try {
|
|
348
|
+
const r = await api('/api/session');
|
|
349
|
+
// Home may have been torn down while the fetch was in flight.
|
|
350
|
+
if (!badge.isConnected) return;
|
|
351
|
+
if (!(r.data && r.data.signedIn)) { stopBadgePoll(); return; }
|
|
352
|
+
const fresh = agentToolsBadge(r.data.agentTools);
|
|
353
|
+
fresh.id = 'agent-tools';
|
|
354
|
+
fresh.style.marginBottom = 'var(--sp-5)';
|
|
355
|
+
badge.replaceWith(fresh);
|
|
356
|
+
} catch (e) {
|
|
357
|
+
/* transient — try again next tick */
|
|
358
|
+
} finally {
|
|
359
|
+
badgePolling = false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function startBadgePoll() {
|
|
364
|
+
stopBadgePoll();
|
|
365
|
+
badgePollTimer = setInterval(pollBadgeOnce, 2000);
|
|
366
|
+
}
|
|
367
|
+
|
|
277
368
|
function renderSignIn(session) {
|
|
369
|
+
stopBadgePoll(); // the sign-in badge is static — no lifecycle to poll
|
|
278
370
|
app.replaceChildren();
|
|
279
371
|
const wrap = el('div', 'auth');
|
|
280
372
|
const card = el('div', 'auth-card');
|
|
@@ -312,7 +404,7 @@ function renderSignIn(session) {
|
|
|
312
404
|
const signup = el('a', 'auth-switch', 'Sign up on the web \\u2197');
|
|
313
405
|
signup.href = WEB_URL; signup.target = '_blank'; signup.rel = 'noopener noreferrer';
|
|
314
406
|
|
|
315
|
-
card.append(form, signup);
|
|
407
|
+
card.append(form, signup, agentToolsBadge(session.agentTools));
|
|
316
408
|
wrap.append(card);
|
|
317
409
|
app.append(wrap);
|
|
318
410
|
}
|
|
@@ -322,7 +414,13 @@ function renderHome(session) {
|
|
|
322
414
|
const bar = el('header', 'titlebar');
|
|
323
415
|
bar.append(logo());
|
|
324
416
|
const spacer = el('span', null); spacer.style.flex = '1'; bar.append(spacer);
|
|
417
|
+
const help = el('button', 'btn btn-ghost', 'How it works');
|
|
418
|
+
help.addEventListener('click', function () { openHowItWorks(help); });
|
|
419
|
+
bar.append(help);
|
|
325
420
|
bar.append(el('span', 'mono muted', session.email || ''));
|
|
421
|
+
const web = el('a', 'btn', 'Open the web app \\u2197');
|
|
422
|
+
web.href = WEB_URL; web.target = '_blank'; web.rel = 'noopener noreferrer';
|
|
423
|
+
bar.append(web);
|
|
326
424
|
const out = el('button', 'btn btn-ghost', 'Sign out');
|
|
327
425
|
out.addEventListener('click', async function () {
|
|
328
426
|
out.disabled = true;
|
|
@@ -332,6 +430,10 @@ function renderHome(session) {
|
|
|
332
430
|
bar.append(out);
|
|
333
431
|
|
|
334
432
|
const main = el('main', 'home');
|
|
433
|
+
const tools = agentToolsBadge(session.agentTools);
|
|
434
|
+
tools.id = 'agent-tools';
|
|
435
|
+
tools.style.marginBottom = 'var(--sp-5)';
|
|
436
|
+
main.append(tools);
|
|
335
437
|
main.append(el('h1', 'home-title', 'Projects'));
|
|
336
438
|
const sub = el('p', 'home-sub muted');
|
|
337
439
|
sub.append(document.createTextNode('Map each project to its folder on '));
|
|
@@ -343,6 +445,9 @@ function renderHome(session) {
|
|
|
343
445
|
|
|
344
446
|
app.append(bar, main);
|
|
345
447
|
loadProjects(list);
|
|
448
|
+
// Keep the badge honest as async registration settles (connecting \\u2192 connected),
|
|
449
|
+
// swapping only the badge element — never the whole home view.
|
|
450
|
+
startBadgePoll();
|
|
346
451
|
}
|
|
347
452
|
|
|
348
453
|
async function loadProjects(list) {
|
|
@@ -350,7 +455,16 @@ async function loadProjects(list) {
|
|
|
350
455
|
const r = await api('/api/projects');
|
|
351
456
|
if (!r.ok) { list.replaceChildren(el('p', 'empty', (r.data && r.data.error) || 'Could not load projects.')); return; }
|
|
352
457
|
const projects = (r.data && r.data.projects) || [];
|
|
353
|
-
if (projects.length === 0) {
|
|
458
|
+
if (projects.length === 0) {
|
|
459
|
+
const box = el('div', 'empty-cta');
|
|
460
|
+
box.append(el('div', 'empty-cta-title', 'No projects yet'));
|
|
461
|
+
box.append(el('p', 'muted', 'A project holds your codebases. Create your first one in the web app, then come back here to attach a codebase.'));
|
|
462
|
+
const cta = el('a', 'btn btn-primary', 'Create your first project in the web app \\u2197');
|
|
463
|
+
cta.href = WEB_URL; cta.target = '_blank'; cta.rel = 'noopener noreferrer';
|
|
464
|
+
box.append(cta);
|
|
465
|
+
list.replaceChildren(box);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
354
468
|
list.replaceChildren.apply(list, projects.map(function (p) { return projectRow(p, list); }));
|
|
355
469
|
}
|
|
356
470
|
|
|
@@ -411,7 +525,20 @@ async function loadCodebases(p, container) {
|
|
|
411
525
|
}
|
|
412
526
|
const codebases = (r.data && r.data.codebases) || [];
|
|
413
527
|
if (codebases.length === 0) {
|
|
414
|
-
|
|
528
|
+
const box = el('div', 'empty-cta is-compact');
|
|
529
|
+
box.append(el('p', 'muted', 'No codebases yet. Point ' + p.name + ' at a folder on this computer that has a git remote.'));
|
|
530
|
+
const cta = el('button', 'btn btn-primary', 'Add your first codebase');
|
|
531
|
+
cta.type = 'button';
|
|
532
|
+
cta.addEventListener('click', function () {
|
|
533
|
+
// Route return-focus to a control that SURVIVES the codebase-list refresh
|
|
534
|
+
// (this cta is inside the container and gets destroyed by loadCodebases),
|
|
535
|
+
// mirroring the Remove button's focus guard.
|
|
536
|
+
const section = container.closest('.project-codebases');
|
|
537
|
+
const survivingAdd = section && section.querySelector('.project-codebases-head button');
|
|
538
|
+
openAddCodebaseDialog(p, function () { loadCodebases(p, container); }, survivingAdd || cta);
|
|
539
|
+
});
|
|
540
|
+
box.append(cta);
|
|
541
|
+
container.replaceChildren(box);
|
|
415
542
|
return;
|
|
416
543
|
}
|
|
417
544
|
container.replaceChildren.apply(container, codebases.map(function (c) { return codebaseRow(c, p, container); }));
|
|
@@ -528,6 +655,81 @@ function openRemoveCodebaseDialog(p, codebase, onRemoved, returnFocus) {
|
|
|
528
655
|
});
|
|
529
656
|
}
|
|
530
657
|
|
|
658
|
+
/** The re-openable "How it works" reference (Phase 3). Static explainer of the
|
|
659
|
+
* three surfaces — the CLI, this Companion, and the web app — in the shared
|
|
660
|
+
* overlay shell (same header/body/footer + Escape/backdrop close as the other
|
|
661
|
+
* dialogs). Content only, no API. */
|
|
662
|
+
function openHowItWorks(returnFocus) {
|
|
663
|
+
const backdrop = el('div', 'overlay-backdrop');
|
|
664
|
+
const panel = el('div', 'overlay-center');
|
|
665
|
+
const dialog = el('div', 'dialog');
|
|
666
|
+
|
|
667
|
+
const header = el('header', 'dialog-header');
|
|
668
|
+
const heading = el('div', 'dialog-heading');
|
|
669
|
+
heading.append(el('h2', null, 'How CTRL+SPC works'));
|
|
670
|
+
const closeBtn = el('button', 'dialog-close');
|
|
671
|
+
closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', 'Close');
|
|
672
|
+
closeBtn.append(xIcon());
|
|
673
|
+
header.append(heading, closeBtn);
|
|
674
|
+
|
|
675
|
+
const body = el('div', 'dialog-body');
|
|
676
|
+
function section(titleNode, descText, extraNode) {
|
|
677
|
+
const wrap = el('div');
|
|
678
|
+
wrap.style.display = 'flex'; wrap.style.flexDirection = 'column'; wrap.style.gap = 'var(--sp-1)';
|
|
679
|
+
wrap.append(titleNode, el('p', 'muted', descText));
|
|
680
|
+
if (extraNode) wrap.append(extraNode);
|
|
681
|
+
return wrap;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// CLI section — inline command names in mono, matching the approved wireframe.
|
|
685
|
+
const cliWrap = el('div');
|
|
686
|
+
cliWrap.style.display = 'flex'; cliWrap.style.flexDirection = 'column'; cliWrap.style.gap = 'var(--sp-1)';
|
|
687
|
+
const cliTitle = el('h3', null, 'The CLI ');
|
|
688
|
+
cliTitle.append(el('code', 'mono', 'cs'));
|
|
689
|
+
const cliDesc = el('p', 'muted');
|
|
690
|
+
cliDesc.append(document.createTextNode('A small program on this computer. It keeps this computer online so your agents can be reached, and it opens this Companion. Run '));
|
|
691
|
+
cliDesc.append(el('code', 'mono', 'cs'));
|
|
692
|
+
cliDesc.append(document.createTextNode(' anytime to open it; '));
|
|
693
|
+
cliDesc.append(el('code', 'mono', 'cs autostart on'));
|
|
694
|
+
cliDesc.append(document.createTextNode(' keeps it running in the background.'));
|
|
695
|
+
cliWrap.append(cliTitle, cliDesc);
|
|
696
|
+
body.append(cliWrap);
|
|
697
|
+
|
|
698
|
+
const compTitle = el('h3', null, 'The Companion ');
|
|
699
|
+
const compHint = el('span', 'muted', '(this app)'); compHint.style.fontWeight = '400';
|
|
700
|
+
compTitle.append(compHint);
|
|
701
|
+
body.append(section(compTitle,
|
|
702
|
+
'Local setup for this computer. Sign in, and attach your codebases — folders on this computer that have a git remote — to your projects. Paths stay on your computer.'));
|
|
703
|
+
|
|
704
|
+
const webLink = el('a', 'btn btn-primary', 'Open the web app \\u2197');
|
|
705
|
+
webLink.href = WEB_URL; webLink.target = '_blank'; webLink.rel = 'noopener noreferrer';
|
|
706
|
+
webLink.style.alignSelf = 'flex-start'; webLink.style.marginTop = 'var(--sp-1)';
|
|
707
|
+
body.append(section(el('h3', null, 'The web app'),
|
|
708
|
+
'Where the work happens. See your projects, create work items, hand a work item to an agent, and tell the agent what to do with it: explore the code, write a document or a plan, answer questions, and more.',
|
|
709
|
+
webLink));
|
|
710
|
+
|
|
711
|
+
const footer = el('div', 'dialog-footer');
|
|
712
|
+
const doneBtn = el('button', 'btn', 'Close'); doneBtn.type = 'button';
|
|
713
|
+
footer.append(doneBtn);
|
|
714
|
+
|
|
715
|
+
dialog.append(header, body, footer);
|
|
716
|
+
panel.append(dialog);
|
|
717
|
+
backdrop.append(panel);
|
|
718
|
+
document.body.append(backdrop);
|
|
719
|
+
doneBtn.focus();
|
|
720
|
+
|
|
721
|
+
function close() {
|
|
722
|
+
backdrop.remove();
|
|
723
|
+
document.removeEventListener('keydown', onKey);
|
|
724
|
+
if (returnFocus && returnFocus.focus) returnFocus.focus();
|
|
725
|
+
}
|
|
726
|
+
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
|
|
727
|
+
document.addEventListener('keydown', onKey);
|
|
728
|
+
backdrop.addEventListener('mousedown', function (e) { if (e.target === backdrop) close(); });
|
|
729
|
+
closeBtn.addEventListener('click', close);
|
|
730
|
+
doneBtn.addEventListener('click', close);
|
|
731
|
+
}
|
|
732
|
+
|
|
531
733
|
/** Screens 8 & 9: locate a not-here codebase on THIS computer. Pick a folder;
|
|
532
734
|
* if its git remote canonicalizes to the codebase's identity, its path is saved
|
|
533
735
|
* locally and this computer is reported as having the codebase. A non-matching
|
package/dist/companion.js
CHANGED
|
@@ -5,6 +5,8 @@ import { openBrowser } from './browser.js';
|
|
|
5
5
|
import { companionToken, getMachineIdentity, readSession, clearSession, readCodebasePaths, writeCodebasePath } from './config.js';
|
|
6
6
|
import { getClient, signIn, NotLoggedIn } from './supabase.js';
|
|
7
7
|
import { startPresence, stopPresence } from './presence.js';
|
|
8
|
+
import { detectAgents } from './agents.js';
|
|
9
|
+
import { toolsServerStatus, agentRegStatus, badgeReason } from './mcp.js';
|
|
8
10
|
import { loadProjects, saveMapping } from './projects.js';
|
|
9
11
|
import { listCodebases, addCodebase, reportLocated, removeCodebase, NotHostedRemoteError } from './codebases.js';
|
|
10
12
|
import { hostedRemoteIdentity } from './git-remote.js';
|
|
@@ -37,6 +39,8 @@ async function isOurCompanion(token) {
|
|
|
37
39
|
* points the browser at it; otherwise becomes the resident server itself.
|
|
38
40
|
*/
|
|
39
41
|
export async function openCompanion() {
|
|
42
|
+
if (!readSession())
|
|
43
|
+
console.log('Welcome to CTRL+SPC — opening your browser to sign in…');
|
|
40
44
|
await serveCompanion({ open: true });
|
|
41
45
|
}
|
|
42
46
|
/**
|
|
@@ -150,6 +154,21 @@ async function readBody(req) {
|
|
|
150
154
|
function str(value) {
|
|
151
155
|
return typeof value === 'string' ? value : '';
|
|
152
156
|
}
|
|
157
|
+
/** The passive "Agent tools" badge state for the companion (feature 05). Phase 3
|
|
158
|
+
* gives it an honest lifecycle — connecting / connected / couldn't-connect — on
|
|
159
|
+
* top of the signed-out and no-agent off-states. The branching itself lives in
|
|
160
|
+
* the pure `badgeReason` (mcp.ts) so it's unit-testable; this only gathers the
|
|
161
|
+
* live signals: whether we're signed in, which supported agents are installed,
|
|
162
|
+
* whether the tools server is up, and each agent's registration status. */
|
|
163
|
+
function agentToolsState(signedIn) {
|
|
164
|
+
const installed = detectAgents().filter((a) => a === 'claude' || a === 'codex');
|
|
165
|
+
return badgeReason({
|
|
166
|
+
signedIn,
|
|
167
|
+
installed,
|
|
168
|
+
serverRunning: toolsServerStatus().running,
|
|
169
|
+
status: agentRegStatus(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
153
172
|
async function handle(req, res, token) {
|
|
154
173
|
if (!allowedHost(req.headers.host)) {
|
|
155
174
|
res.writeHead(421, { 'Content-Type': 'text/plain' }).end('Misdirected request.');
|
|
@@ -202,6 +221,7 @@ async function handleApi(req, res, path, query) {
|
|
|
202
221
|
machineName: machine.name,
|
|
203
222
|
platform: process.platform,
|
|
204
223
|
version: VERSION,
|
|
224
|
+
agentTools: agentToolsState(email !== null),
|
|
205
225
|
});
|
|
206
226
|
return;
|
|
207
227
|
}
|
|
@@ -229,7 +249,9 @@ async function handleApi(req, res, path, query) {
|
|
|
229
249
|
return;
|
|
230
250
|
}
|
|
231
251
|
if (req.method === 'POST' && path === '/api/logout') {
|
|
232
|
-
|
|
252
|
+
// Logout also unregisters ctrl-spc from the detected agents (Phase 3), so no
|
|
253
|
+
// dead server entry is left behind for the next agent run.
|
|
254
|
+
await stopPresence({ markOffline: true, unregister: true });
|
|
233
255
|
clearSession();
|
|
234
256
|
json(res, 200, { ok: true });
|
|
235
257
|
return;
|
package/dist/config.js
CHANGED
|
@@ -199,7 +199,20 @@ export function writeCodebasePath(identity, localPath) {
|
|
|
199
199
|
* On disk (not per-process) so `cs open` and the resident server agree on it.
|
|
200
200
|
*/
|
|
201
201
|
export function companionToken() {
|
|
202
|
-
|
|
202
|
+
return persistentToken('companion-token');
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Stable per-install MCP tools-server token, same contract as companionToken.
|
|
206
|
+
* The loopback /mcp endpoint requires it, and it is embedded in the URL that
|
|
207
|
+
* registration writes into Claude/Codex configs — so only clients CTRL+SPC
|
|
208
|
+
* registered (or that can read this 0600 file) can reach the tools, not any
|
|
209
|
+
* process that happens to find the loopback port.
|
|
210
|
+
*/
|
|
211
|
+
export function mcpToken() {
|
|
212
|
+
return persistentToken('mcp-token');
|
|
213
|
+
}
|
|
214
|
+
function persistentToken(name) {
|
|
215
|
+
const path = filePath(name);
|
|
203
216
|
if (existsSync(path)) {
|
|
204
217
|
try {
|
|
205
218
|
const token = readFileSync(path, 'utf8').trim();
|
package/dist/env.js
CHANGED
|
@@ -10,8 +10,18 @@ export const SUPABASE_KEY = process.env.CTRL_SPC_SUPABASE_KEY || 'sb_publishable
|
|
|
10
10
|
export const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
11
11
|
/** Command (ping) poll cadence. */
|
|
12
12
|
export const COMMAND_POLL_INTERVAL_MS = 3_000;
|
|
13
|
+
/** How long a work session stays "working" after the agent's last ctrl-spc tool
|
|
14
|
+
* call, with no further activity. The companion keeps last_seen_at fresh within
|
|
15
|
+
* this window; once it lapses (agent finished, Ctrl-C'd, crashed, or asleep) the
|
|
16
|
+
* session is flipped to 'ended' so the board chip clears. Must exceed the
|
|
17
|
+
* heartbeat interval and the web's freshness window. */
|
|
18
|
+
export const SESSION_TTL_MS = 120_000;
|
|
13
19
|
/** Companion GUI port. Distinct from the v1 CLI's companion (4573) so an
|
|
14
20
|
* installed v1 and the v2 experiment never contend for the same port. */
|
|
15
21
|
export const COMPANION_PORT = Number(process.env.CTRL_SPC_V2_COMPANION_PORT) || 4577;
|
|
22
|
+
/** Local `ctrl-spc` MCP tools-server port. Fixed so the value written into
|
|
23
|
+
* Claude's config stays stable across restarts. Distinct from the v2 companion
|
|
24
|
+
* (4577) and both v1 ports (MCP 4571, companion 4573) so nothing contends. */
|
|
25
|
+
export const TOOLS_SERVER_PORT = Number(process.env.CTRL_SPC_V2_TOOLS_PORT) || 4579;
|
|
16
26
|
/** Where "Sign up on the web" and the app's own sign-in live. */
|
|
17
27
|
export const WEB_APP_URL = process.env.CTRL_SPC_WEB_URL || 'https://ctrl-spc.com';
|