@ctrl-spc/cs 0.7.1 → 0.7.2

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 CHANGED
@@ -43,7 +43,7 @@ cs Open the Companion app (the front door)
43
43
  cs open Open the Companion app in your browser
44
44
  cs login Sign in from the terminal and link this computer
45
45
  cs start Come online now, no window (used by auto-start)
46
- cs status Show sign-in state, computer, and detected agents
46
+ cs status Show the whole setup state and the one next step to take
47
47
  cs autostart on Come online automatically at login
48
48
  cs autostart off Stop coming online at login
49
49
  cs logout Sign this computer out
@@ -195,11 +195,15 @@ export function codexRunConfigToml(server, runTodoId, runtimePlatform = process.
195
195
  * codex worker reached for it instead of this product's `dispatch` tool;
196
196
  * no level 3 run was ever started through the record.
197
197
  *
198
- * Codex 0.148 made `multi_agent` the stable feature switch and changed
199
- * `[agents]` into a role table. The old `[agents] enabled = false` now makes
200
- * the current Desktop runtime reject the whole config before a run starts.
201
- * `features.multi_agent = false` is therefore the surface-level absence rule
202
- * on the current runtime, matching the documented configuration contract.
198
+ * `[agents] enabled = false` is the ONLY key that removes it, and it is
199
+ * MEASURED, never read off documentation. `features.multi_agent = false` is
200
+ * the trap: it parses, it reads like the answer, and the tool is still there.
201
+ * Re-measured 2026-08-25 by spawning codex with each config and asking it to
202
+ * delegate: with `multi_agent = false` the stream still carries
203
+ * `collab_tool_call`, and the agent then quietly did the work itself; with
204
+ * `[agents] enabled = false` the tool is gone and the run starts normally.
205
+ * Checked on 0.149.0-alpha.4.1 (macOS) and 0.148.0-alpha.8 (Windows), so the
206
+ * claim that a newer runtime rejects this key does not hold on either.
203
207
  *
204
208
  * v2's claude workers are deliberately still granted `Task` (see
205
209
  * `orchestrator.ts`), so this is an EXPLICIT argument rather than a change to
@@ -228,8 +232,8 @@ subagentsEnabled = true, persistentPanelOwner = false) {
228
232
  /* The account's own connectors, which no config.toml grants and no argv
229
233
  removes. See the block comment above. */
230
234
  'apps = false',
231
- ...(!subagentsEnabled ? ['multi_agent = false'] : []),
232
235
  '',
236
+ ...(!subagentsEnabled ? ['[agents]', 'enabled = false', ''] : []),
233
237
  ];
234
238
  /* Native Windows must name its sandbox implementation. Without this block,
235
239
  codex silently resolves a requested workspace-write run as read-only and
package/dist/config.js CHANGED
@@ -234,6 +234,23 @@ export function companionToken() {
234
234
  export function mcpToken() {
235
235
  return persistentToken('mcp-token');
236
236
  }
237
+ /**
238
+ * The stored mcp token, or null when this install has never minted one. Unlike
239
+ * mcpToken(), it never creates the file — a read-only command (`cs status`)
240
+ * must not mint a token as a side effect of reporting state, and "no token" is
241
+ * itself the honest answer to whether anything could be registered yet.
242
+ */
243
+ export function readMcpToken() {
244
+ const path = filePath('mcp-token');
245
+ if (!existsSync(path))
246
+ return null;
247
+ try {
248
+ return readFileSync(path, 'utf8').trim() || null;
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
237
254
  function persistentToken(name) {
238
255
  const path = filePath(name);
239
256
  if (existsSync(path)) {
package/dist/index.js CHANGED
@@ -4,33 +4,106 @@ import { runDaemon } from './daemon.js';
4
4
  import { openCompanion } from './companion.js';
5
5
  import { autostartOn, autostartOff } from './autostart.js';
6
6
  import { detectAgents } from './agents.js';
7
- import { getMachineIdentity, clearSession } from './config.js';
8
- import { getClient, NotLoggedIn } from './supabase.js';
7
+ import { getMachineIdentity, clearSession, readSession } from './config.js';
8
+ import { getClient } from './supabase.js';
9
+ import { foreignToolsServerAlive, claudeRegisteredOnDisk, codexRegisteredOnDisk } from './mcp.js';
10
+ import { panelCommand } from './panel3/cli.js';
9
11
  const HELP = `cs — CTRL+SPC
10
12
 
11
13
  cs Open the Companion app (the front door)
12
14
  cs open Open the Companion app in your browser
13
15
  cs login Sign in from the terminal and link this computer
14
16
  cs start Come online and answer cards, no window (used by auto-start)
15
- cs status Show sign-in state, computer, and detected agents
17
+ cs status Show the whole setup state and the one next step to take
16
18
  cs autostart on Come online automatically at login
17
19
  cs autostart off Stop coming online at login
18
20
  cs logout Sign this computer out
21
+
22
+ cs say "<text>" Start a card and put your message on it
23
+ cs say --project <p> "<text>" Start it filed under that project (id or name)
24
+ cs say --card <id> "<text>" Add a message to a card you already have
25
+ cs answer <id> "<text>" Answer a question a card is waiting on you for
26
+ cs show Every card
27
+ cs show <id> One card in full, or one run with its report
28
+
19
29
  cs help Show this help
20
30
  `;
31
+ /**
32
+ * The whole setup state, and the ONE next step to take, for an agent — the
33
+ * reader of this command in every story is Claude Code or Codex, not a person
34
+ * at a prompt, so every step below addresses the agent and names the user only
35
+ * for sign-in, which is the one step only the user can take.
36
+ *
37
+ * A non-zero exit means "not fully set up", NEVER "a command failed". A machine
38
+ * mid-setup exits 1 while everything is working correctly.
39
+ */
21
40
  async function status() {
22
41
  const id = getMachineIdentity();
23
- const agents = detectAgents();
24
- console.log(`Computer: ${id.name}`);
25
- console.log(`Agents: ${agents.length ? agents.join(', ') : 'none'}`);
26
- try {
27
- const client = await getClient();
28
- const { data } = await client.auth.getUser();
29
- console.log(`Signed in as ${data.user?.email ?? 'unknown'}`);
42
+ const installed = detectAgents();
43
+ /* Signed-out is a FILE question, not a network one. getClient() throws
44
+ NotLoggedIn whenever setSession fails FOR ANY REASON, including a network
45
+ failure (supabase.ts), so trusting it would tell a signed-in user on bad
46
+ wifi to go and sign in again — the confident-wrong instruction this feature
47
+ exists to kill. readSession() returning null is the only true "signed out". */
48
+ const stored = readSession();
49
+ let signedIn = false;
50
+ let email = null;
51
+ let sessionProblem = null;
52
+ if (stored) {
53
+ try {
54
+ const client = await getClient();
55
+ const { data } = await client.auth.getUser();
56
+ email = data.user?.email ?? null;
57
+ signedIn = true;
58
+ }
59
+ catch (err) {
60
+ /* The REASON only. getClient()'s NotLoggedIn message ends with its own
61
+ "Run `cs login` again.", which would print twice in the step below. */
62
+ const raw = err instanceof Error ? err.message : String(err);
63
+ sessionProblem = raw.replace(/\s*Run `cs login` again\.?\s*$/, '').replace(/\.$/, '');
64
+ }
65
+ }
66
+ const serving = await foreignToolsServerAlive();
67
+ /* Gated on `serving` because a plain shutdown DELIBERATELY leaves the agent
68
+ config entries in place (presence.ts), so an ungated read prints
69
+ "registered" against a daemon that is not there. */
70
+ const registered = {
71
+ claude: serving && claudeRegisteredOnDisk(),
72
+ codex: serving && codexRegisteredOnDisk(),
73
+ };
74
+ console.log(`Computer: ${id.name}`);
75
+ console.log(`Signed in: ${signedIn ? (email ?? 'unknown') : stored ? 'saved sign-in unusable' : 'Not signed in'}`);
76
+ console.log(`Daemon: ${serving ? 'running' : 'not running'}`);
77
+ console.log(`Agents: ${installed.length ? installed.join(', ') : 'none installed'}`);
78
+ for (const agent of installed) {
79
+ console.log(` ${agent.padEnd(8)} ${registered[agent] ? 'registered' : 'not registered'}`);
80
+ }
81
+ console.log('');
82
+ const unregistered = installed.filter((a) => !registered[a]);
83
+ if (!stored) {
84
+ console.log('Nobody is signed in. Ask the user to run `cs login` themselves: it opens a');
85
+ console.log('browser and waits up to five minutes, so do not run it yourself. They sign in');
86
+ console.log('with the same email and password they use on ctrl-spc.com. Then run `cs status`');
87
+ console.log('again.');
88
+ }
89
+ else if (!signedIn) {
90
+ console.log(`The saved sign-in could not be used: ${sessionProblem ?? 'unknown'}. Ask the user to run`);
91
+ console.log('`cs login` again.');
92
+ }
93
+ else if (!serving) {
94
+ console.log('Nothing is serving the tools. `cs start` never exits on its own, so start it in');
95
+ console.log('the background and leave it running, then run `cs status` again.');
96
+ }
97
+ else if (unregistered.length) {
98
+ console.log(`The tools are being served but are not registered into ${unregistered.join(' or ')}. Restart the`);
99
+ console.log('background daemon, then run `cs status` again.');
30
100
  }
31
- catch (err) {
32
- console.log(err instanceof NotLoggedIn ? 'Not signed in. Run `cs login`.' : `Session error: ${err.message}`);
101
+ else {
102
+ console.log('Tools are registered. If this agent cannot see them, restart it. An agent picks');
103
+ console.log('up MCP servers only when it starts.');
104
+ return;
33
105
  }
106
+ process.exitCode = 1;
34
107
  }
35
108
  async function main() {
36
109
  const cmd = process.argv[2];
@@ -44,6 +117,14 @@ async function main() {
44
117
  case 'logout':
45
118
  console.log(clearSession() ? 'Signed out.' : 'Was not signed in.');
46
119
  return;
120
+ /* THE PANEL'S OWN COMMANDS, ROUTED WHOLE. The person types one CLI, so the
121
+ card commands are `cs` subcommands; the argument handling stays inside
122
+ `panel3/`, which is the one import this file makes into it (named in
123
+ `test/panel3-isolation.contract.test.mjs`). */
124
+ case 'say':
125
+ case 'answer':
126
+ case 'show':
127
+ return panelCommand(process.argv.slice(2));
47
128
  case 'autostart':
48
129
  if (arg === 'on')
49
130
  return autostartOn();
package/dist/mcp.js CHANGED
@@ -13,7 +13,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
13
13
  import { z } from 'zod';
14
14
  import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
15
15
  import { agentPath } from './agents.js';
16
- import { mcpToken, readSession } from './config.js';
16
+ import { mcpToken, readMcpToken, readSession } from './config.js';
17
17
  import { readPngScreenshot, } from './screenshots.js';
18
18
  /* 18c Slice 7 correction: the product captures the PNG itself, because a
19
19
  spawned worker has no reachable way to run a capture command. The whole
@@ -13135,7 +13135,7 @@ let foreignProbeCache = null;
13135
13135
  /** Whether ANOTHER process's ctrl-spc tools server answers /health on the fixed
13136
13136
  * port. Never throws; refusals, timeouts, and non-ctrl-spc answers all read as
13137
13137
  * "not alive" (→ the badge keeps today's failed semantics). */
13138
- async function foreignToolsServerAlive(fetchImpl) {
13138
+ export async function foreignToolsServerAlive(fetchImpl = fetch) {
13139
13139
  const now = Date.now();
13140
13140
  if (foreignProbeCache &&
13141
13141
  foreignProbeCache.fetchImpl === fetchImpl &&
@@ -13162,14 +13162,20 @@ async function foreignToolsServerAlive(fetchImpl) {
13162
13162
  * `claude mcp add --scope user` writes) carries a `ctrl-spc` entry whose url
13163
13163
  * matches the CURRENT mcp token and tools port. A stale entry from an old
13164
13164
  * token or port does not count — it would 401 against the live server. */
13165
- function claudeRegisteredOnDisk() {
13165
+ export function claudeRegisteredOnDisk() {
13166
13166
  try {
13167
+ // readMcpToken, never mcpToken: `cs status` must not mint a token as a side
13168
+ // effect of a read. No token on disk means this install never launched, so
13169
+ // nothing can be registered against it either way.
13170
+ const token = readMcpToken();
13171
+ if (!token)
13172
+ return false;
13167
13173
  const raw = readFileSync(join(homedir(), '.claude.json'), 'utf8');
13168
13174
  const parsed = JSON.parse(raw);
13169
13175
  const url = parsed?.mcpServers?.['ctrl-spc']?.url;
13170
13176
  return (typeof url === 'string' &&
13171
13177
  url.includes(`127.0.0.1:${TOOLS_SERVER_PORT}/mcp`) &&
13172
- url.includes(`token=${mcpToken()}`));
13178
+ url.includes(`token=${token}`));
13173
13179
  }
13174
13180
  catch {
13175
13181
  return false;
@@ -13260,11 +13266,14 @@ export function ensureCodexMcpApproval(path = codexConfigPath()) {
13260
13266
  * written (current token + port). A substring check on purpose: it holds
13261
13267
  * regardless of how Codex quotes/normalizes the TOML around it, and the
13262
13268
  * tokened url is unique to this install. */
13263
- function codexRegisteredOnDisk() {
13269
+ export function codexRegisteredOnDisk() {
13264
13270
  try {
13271
+ const token = readMcpToken(); // see claudeRegisteredOnDisk: never mint here
13272
+ if (!token)
13273
+ return false;
13265
13274
  const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex');
13266
13275
  const raw = readFileSync(join(codexHome, 'config.toml'), 'utf8');
13267
- return raw.includes(`http://127.0.0.1:${TOOLS_SERVER_PORT}/mcp?token=${mcpToken()}`);
13276
+ return raw.includes(`http://127.0.0.1:${TOOLS_SERVER_PORT}/mcp?token=${token}`);
13268
13277
  }
13269
13278
  catch {
13270
13279
  return false;
@@ -1,17 +1,17 @@
1
1
  /**
2
- * ═══ AGENT PANEL v3: `cs3 answer`, the person settling a question. ═══
2
+ * ═══ AGENT PANEL v3: `cs answer`, the person settling a question. ═══
3
3
  *
4
4
  * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
5
5
  * import it.
6
6
  *
7
7
  * ---------------------------------------------------------------------------
8
- * ═══ IT IS `cs3 say`'s SIBLING, AND IT IS A SEPARATE COMMAND ON PURPOSE. ═══
8
+ * ═══ IT IS `cs say`'s SIBLING, AND IT IS A SEPARATE COMMAND ON PURPOSE. ═══
9
9
  *
10
10
  * Both are the person putting something on the record and returning, with no
11
11
  * spawn to wait for. They differ in what the record does with it, and that
12
12
  * difference is exactly why they are not one command: a message is new work, and
13
13
  * an answer is the thing one particular piece of stopped work was waiting for.
14
- * `cs3 say --card <id> "the answer"` would put the words on the card as a fresh
14
+ * `cs say --card <id> "the answer"` would put the words on the card as a fresh
15
15
  * message, leave the question open, and leave the run that asked it stopped
16
16
  * forever — which is the fenced-off answer ux.md spends a section on, arrived at
17
17
  * by the shortest route.
@@ -48,12 +48,12 @@
48
48
  // `import type` loses its v3 header in the published `dist/`.
49
49
  import { at, out, returned, signedInClient } from './client.js';
50
50
  import { ASK_CONTENT_COLUMNS, withAskContent } from './show.js';
51
- const USAGE = 'usage: cs3 answer <question-id> "<text>"';
51
+ const USAGE = 'usage: cs answer <question-id> "<text>"';
52
52
  /**
53
- * The two arguments, and a second bare word refused rather than joined: `cs3
53
+ * The two arguments, and a second bare word refused rather than joined: `cs
54
54
  * answer <id> yes it is` is an answer the shell already split, and gluing it back
55
55
  * together would be this command guessing at what the person typed. The same rule
56
- * `cs3 say` applies, for the same reason.
56
+ * `cs say` applies, for the same reason.
57
57
  */
58
58
  function parseArgs(args) {
59
59
  const words = args.filter((a) => !a.startsWith('--'));
@@ -81,7 +81,7 @@ function parseArgs(args) {
81
81
  *
82
82
  * ═══ AND THE WAITING ONE IS ONLY SAID WHEN IT IS TRUE. ═══ It used to say the
83
83
  * question "may yet be settled without troubling you" whatever the record said —
84
- * including for a question `cs3 show` had already called stalled, because the
84
+ * including for a question `cs show` had already called stalled, because the
85
85
  * agent it went to had ended without dealing with it. Two surfaces, opposite
86
86
  * claims, and the one the person can act on was the one that was wrong. Those
87
87
  * are now answerable, so the only refusal left here is the honest one: it has
@@ -94,15 +94,15 @@ async function whyNot(client, askId) {
94
94
  .eq('id', askId), 'read', `question ${askId}`));
95
95
  const ask = asks[0];
96
96
  if (!ask) {
97
- return `there is no question with id ${askId}. \`cs3 show\` lists the cards and the questions on them.`;
97
+ return `there is no question with id ${askId}. \`cs show\` lists the cards and the questions on them.`;
98
98
  }
99
99
  if (ask.answered_at) {
100
100
  return (`that question was already answered ${at(ask.answered_at)}, with:\n ${ask.answer ?? '(nothing)'}\n`
101
101
  + 'Answering it again would rewrite a decision the work has already been started on. Send a '
102
- + `message instead: cs3 say --card ${ask.card_id} "<text>"`);
102
+ + `message instead: cs say --card ${ask.card_id} "<text>"`);
103
103
  }
104
104
  return ('that question has not reached you: it is with the work it came from, which has not had its go '
105
- + 'at it yet and may settle it without troubling you. `cs3 show` says who has it, and it becomes '
105
+ + 'at it yet and may settle it without troubling you. `cs show` says who has it, and it becomes '
106
106
  + 'yours to answer if they end without dealing with it.');
107
107
  }
108
108
  export async function answer(args) {
@@ -151,7 +151,7 @@ export function answeredLines(askId, cardId, state) {
151
151
  `card ${cardId} in hand again`,
152
152
  '',
153
153
  ' Whoever was waiting on this is started again with it, wherever they had got to.',
154
- ` cs3 show ${cardId}`,
154
+ ` cs show ${cardId}`,
155
155
  ];
156
156
  }
157
157
  return [
@@ -161,6 +161,6 @@ export function answeredLines(askId, cardId, state) {
161
161
  ' NOBODY IS BEING STARTED FOR THIS. The answer is on the record and nothing is waiting for',
162
162
  ' it, because the work that asked was stopped. Send a message if you want this card picked',
163
163
  ' up again:',
164
- ` cs3 say --card ${cardId} "<text>"`,
164
+ ` cs say --card ${cardId} "<text>"`,
165
165
  ];
166
166
  }