@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 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
+ }
@@ -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) { list.replaceChildren(el('p', 'empty', 'No projects yet. Create one in the web app.')); return; }
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
- container.replaceChildren(el('p', 'codebases-empty', 'No codebases yet. Add one from a folder that has a git remote.'));
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
- await stopPresence({ markOffline: true });
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
- const path = filePath('companion-token');
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';