@ctrl-spc/cs 0.7.1 → 0.7.3

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/login.js CHANGED
@@ -137,7 +137,15 @@ function renderPage(state) {
137
137
  input { padding:8px 10px; font-size:14px; border:1px solid #ccc; border-radius:6px; }
138
138
  button[type=submit] { margin-top:6px; padding:9px; border:none; border-radius:6px; background:#111; color:#fff; cursor:pointer; }
139
139
  .error { color:#c0392b; font-size:13px; min-height:16px; }
140
- @media (prefers-color-scheme: dark){ body{background:#111;color:#eee;} input{background:#1c1c1c;border-color:#333;color:#eee;} button[type=submit]{background:#eee;color:#111;} }
140
+ @media (prefers-color-scheme: dark){
141
+ body{background:#111;color:#eee;} p.sub{color:#999;}
142
+ input{background:#1c1c1c;border-color:#333;color:#eee;}
143
+ button[type=submit]{background:#eee;color:#111;}
144
+ .tabs{border-bottom-color:#333;}
145
+ .tabs button{color:#999;}
146
+ .tabs button.active{color:#fff;border-bottom-color:#fff;}
147
+ .error{color:#ff6b5e;}
148
+ }
141
149
  </style></head><body>
142
150
  <div id="app">
143
151
  <h1>CTRL+SPC</h1><p class="sub">Sign in to link this computer.</p>
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
@@ -4756,7 +4756,13 @@ export async function listCredentialsHandler(client) {
4756
4756
  * returns `{secret, username}`; that maps to screen 8's shapes —
4757
4757
  * api_key → `{kind, secret}`, login → `{kind, username, password}`.
4758
4758
  */
4759
- export async function getCredentialHandler(client, args,
4759
+ export async function getCredentialHandler(client,
4760
+ // `id` is the web's copy-for-agent handle, and it is the ONLY thing that can
4761
+ // resolve the ambiguity the name path can only report: names are unique per
4762
+ // (org, creator), so two credentials the caller can read may share one, and
4763
+ // by name this tool could previously do nothing but tell the user to go
4764
+ // rename something. A pasted id names the row the user actually clicked.
4765
+ args,
4760
4766
  // 18d Slice 3. OPTIONAL and trailing, so the existing two-argument call sites
4761
4767
  // (and their tests) are untouched. Absent means "not a run" — see
4762
4768
  // `rememberSecret`.
@@ -4765,26 +4771,38 @@ runTodoId = null) {
4765
4771
  // whatever casing the agent happened to type: the match below is
4766
4772
  // case-insensitive, so "talenttrack read token" must still redact as
4767
4773
  // "[redacted: TalentTrack read token]".
4774
+ const wantedId = typeof args.id === 'string' ? args.id.trim() : '';
4775
+ if (!wantedId && !args.name) {
4776
+ return errorResult('get_credential requires name — as listed by list_credentials.');
4777
+ }
4768
4778
  let match;
4769
4779
  try {
4770
4780
  const rows = (await must(client
4771
4781
  .from('credentials')
4772
4782
  .select('id, name, kind, org_id, created_by')
4773
4783
  .order('created_at', { ascending: true }))) ?? [];
4774
- const wanted = args.name.toLowerCase();
4775
- // Uniqueness is per (org, creator): the caller can see two credentials
4776
- // with the same lowercased name across two of their orgs, or within one
4777
- // org (their own plus one shared to them by another creator). Never guess
4778
- // which secret was meant. org_id/created_by are read only to word this
4779
- // error; they never appear in tool output.
4780
- const matches = rows.filter((row) => row.name.toLowerCase() === wanted);
4781
- if (matches.length > 1) {
4782
- const sameOrg = matches.every((row) => row.org_id === matches[0].org_id);
4783
- return errorResult(sameOrg
4784
- ? `credential name "${args.name}" is ambiguous — you can access more than one credential with this name in the same organization (e.g. your own and one shared with you); delete or re-create yours under a different name, or ask an org owner to revoke a shared copy`
4785
- : `credential name "${args.name}" is ambiguous you have access to credentials with this name in more than one of your organizations; delete or re-create one under a different name`);
4786
- }
4787
- match = matches[0];
4784
+ if (wantedId) {
4785
+ // An id names one row, so there is no ambiguity branch to reach. RLS
4786
+ // already filtered the rows, so a credential the caller cannot read is
4787
+ // simply absent and falls through to the uniform not-found below.
4788
+ match = rows.find((row) => row.id === wantedId);
4789
+ }
4790
+ else {
4791
+ const wanted = args.name.toLowerCase();
4792
+ // Uniqueness is per (org, creator): the caller can see two credentials
4793
+ // with the same lowercased name across two of their orgs, or within one
4794
+ // org (their own plus one shared to them by another creator). Never guess
4795
+ // which secret was meant. org_id/created_by are read only to word this
4796
+ // error; they never appear in tool output.
4797
+ const matches = rows.filter((row) => row.name.toLowerCase() === wanted);
4798
+ if (matches.length > 1) {
4799
+ const sameOrg = matches.every((row) => row.org_id === matches[0].org_id);
4800
+ return errorResult(sameOrg
4801
+ ? `credential name "${args.name}" is ambiguous — you can access more than one credential with this name in the same organization (e.g. your own and one shared with you); delete or re-create yours under a different name, or ask an org owner to revoke a shared copy`
4802
+ : `credential name "${args.name}" is ambiguous — you have access to credentials with this name in more than one of your organizations; delete or re-create one under a different name`);
4803
+ }
4804
+ match = matches[0];
4805
+ }
4788
4806
  }
4789
4807
  catch (err) {
4790
4808
  return errorResult(`get_credential failed: ${err.message}`);
@@ -4978,9 +4996,51 @@ export async function listSkillsHandler(client, args) {
4978
4996
  * survives a compaction — an agent that has lost `get_skill`'s output still has
4979
4997
  * the skill's name in the conversation and can still reach its files.
4980
4998
  */
4981
- async function resolveSkill(client, rawName, rawOrgId, tool) {
4999
+ /**
5000
+ * Resolve a skill by its row id — the handle the web's copy-for-agent button
5001
+ * pastes. An id names ONE row, so neither of the name path's two ambiguity
5002
+ * errors can arise and `org_id` has nothing left to disambiguate: the id
5003
+ * already decided which org's skill this is, and the bundle it belongs to says
5004
+ * which org that was. RLS does the access check, so a row the caller cannot see
5005
+ * is simply not found.
5006
+ */
5007
+ async function resolveSkillById(client, id, tool) {
5008
+ const rows = (await must(client
5009
+ .from('skills')
5010
+ .select('id, bundle_id, name, description, relative_path, status, status_reason')
5011
+ .eq('id', id)
5012
+ .is('deleted_at', null))) ?? [];
5013
+ const row = rows[0];
5014
+ if (!row) {
5015
+ return {
5016
+ ok: false,
5017
+ error: errorResult(`${tool}: no skill with id ${id}. It may have been deleted, or belong to an organization you ` +
5018
+ 'cannot see. Call list_skills to see the skills that exist.'),
5019
+ };
5020
+ }
5021
+ // The bundle is what carries the org, and only a LIVE bundle counts: a skill
5022
+ // row can outlive the archiving of the bundle that brought it in.
5023
+ const bundles = await liveSkillBundles(client, null);
5024
+ const orgId = bundles.find((bundle) => bundle.id === row.bundle_id)?.org_id;
5025
+ if (!orgId) {
5026
+ return {
5027
+ ok: false,
5028
+ error: errorResult(`${tool}: skill "${row.name}" belongs to a skill pack that is no longer active. ` +
5029
+ 'Re-import the pack in the web app.'),
5030
+ };
5031
+ }
5032
+ return { ok: true, value: { row, orgId } };
5033
+ }
5034
+ async function resolveSkill(client, rawName, rawOrgId, tool,
5035
+ // The web's copy-for-agent handle. A name is still the addressing an agent
5036
+ // reaches for unaided (it survives a compaction; see this section's header),
5037
+ // so `id` is the ALTERNATIVE, not the replacement: it exists because the
5038
+ // Skills page copies `/ctrl-spc skill <id>` and a pasted row must resolve to
5039
+ // the row the user clicked, never to a same-named skill in another org.
5040
+ rawId = undefined) {
5041
+ const wantedId = typeof rawId === 'string' ? rawId.trim() : '';
4982
5042
  const wanted = typeof rawName === 'string' ? rawName.trim() : '';
4983
- if (!wanted) {
5043
+ if (!wantedId && !wanted) {
4984
5044
  return {
4985
5045
  ok: false,
4986
5046
  error: errorResult(`${tool} requires name — the name of the skill to read, as listed by list_skills.`),
@@ -4989,6 +5049,8 @@ async function resolveSkill(client, rawName, rawOrgId, tool) {
4989
5049
  const filter = skillOrgFilter(rawOrgId, tool);
4990
5050
  if (!filter.ok)
4991
5051
  return filter;
5052
+ if (wantedId)
5053
+ return resolveSkillById(client, wantedId, tool);
4992
5054
  const bundles = await liveSkillBundles(client, filter.value);
4993
5055
  const orgIdByBundle = new Map(bundles.map((bundle) => [bundle.id, bundle.org_id]));
4994
5056
  const rows = orgIdByBundle.size
@@ -5167,7 +5229,7 @@ async function listSkillBundleFiles(client, orgId, bundleId, relativePath) {
5167
5229
  }
5168
5230
  export async function getSkillHandler(client, args) {
5169
5231
  try {
5170
- const resolved = await resolveSkill(client, args?.name, args?.org_id, 'get_skill');
5232
+ const resolved = await resolveSkill(client, args?.name, args?.org_id, 'get_skill', args?.id);
5171
5233
  if (!resolved.ok)
5172
5234
  return resolved.error;
5173
5235
  const { row: match, orgId } = resolved.value;
@@ -5276,7 +5338,7 @@ export async function readSkillFileHandler(client, args) {
5276
5338
  if (!wantedPath) {
5277
5339
  return errorResult('read_skill_file requires path — one of the paths get_skill listed in bundle_files.');
5278
5340
  }
5279
- const resolved = await resolveSkill(client, args?.name, args?.org_id, 'read_skill_file');
5341
+ const resolved = await resolveSkill(client, args?.name, args?.org_id, 'read_skill_file', args?.id);
5280
5342
  if (!resolved.ok)
5281
5343
  return resolved.error;
5282
5344
  const { row: match, orgId } = resolved.value;
@@ -5883,6 +5945,58 @@ async function unnarrowedEmptyNote(client, projectId) {
5883
5945
  }
5884
5946
  return NO_PROJECT_DOCUMENTS_NOTE;
5885
5947
  }
5948
+ export async function getDocumentHandler(client, args) {
5949
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
5950
+ if (!id) {
5951
+ return errorResult('get_document requires id — the id of the document to read, as the web app’s “Copy for agent” ' +
5952
+ 'button pastes it.');
5953
+ }
5954
+ // Shape-checked once, here at the boundary, exactly as resolveContextProject
5955
+ // does with task_id: without it a mistyped id comes back as a Postgres
5956
+ // "invalid input syntax for type uuid", which reads like a bug in the tool.
5957
+ if (!UUID_RE.test(id)) {
5958
+ return errorResult(`get_document: not a valid document id: "${id}".`);
5959
+ }
5960
+ try {
5961
+ // Context documents first, then instructions. RLS scopes both reads, so a
5962
+ // row the caller may not see is simply absent and falls through to the
5963
+ // not-found below.
5964
+ const documents = await must(client
5965
+ .from('project_documents')
5966
+ .select('title, content, codebase_id, type')
5967
+ .eq('id', id));
5968
+ const document = (documents ?? [])[0];
5969
+ if (document)
5970
+ return textResult(shapeDocument(document));
5971
+ const instructions = await must(client.from('agent_instructions').select('title, content, codebase_id').eq('id', id));
5972
+ const instruction = (instructions ?? [])[0];
5973
+ if (instruction) {
5974
+ return textResult({
5975
+ ...shapeDocument(instruction),
5976
+ // Said plainly, so an agent that fetched one does not conclude these are
5977
+ // reference material it may choose to consult: it already has them.
5978
+ note: 'This is an agent instruction. Every instruction on this project is already delivered in ' +
5979
+ 'your prompt — reading one here does not make it optional.',
5980
+ });
5981
+ }
5982
+ return errorResult(`get_document: no document with id ${id}. It may have been deleted, or belong to a project you ` +
5983
+ 'cannot see. Call get_project_context to read the documents on the project you are working.');
5984
+ }
5985
+ catch (err) {
5986
+ return errorResult(`get_document failed: ${errorMessage(err)}`);
5987
+ }
5988
+ }
5989
+ /** The one shape both tables report in, so a caller never has to branch on
5990
+ * which table answered. `scope` is what `codebase_id` MEANS — null is the
5991
+ * project's own document, a value is that codebase's. */
5992
+ function shapeDocument(row) {
5993
+ return {
5994
+ title: row.title,
5995
+ ...(row.type ? { type: row.type } : {}),
5996
+ scope: row.codebase_id === null ? 'project' : 'codebase',
5997
+ content: row.content,
5998
+ };
5999
+ }
5886
6000
  // ---------------------------------------------------------------------------
5887
6001
  // Project context (feature 13a, Phase 4) — `propose_project_context`.
5888
6002
  //
@@ -9802,6 +9916,7 @@ export const TOOL_NAMES = [
9802
9916
  'get_skill',
9803
9917
  'read_skill_file',
9804
9918
  'get_project_context',
9919
+ 'get_document',
9805
9920
  'propose_project_context',
9806
9921
  'list_product_ideas',
9807
9922
  'create_product_idea',
@@ -11216,7 +11331,16 @@ runTodoIdSource = null) {
11216
11331
  'comments, artifacts, documents and questions, and name the credential instead. Never refuse the ' +
11217
11332
  'work to avoid touching the value.',
11218
11333
  inputSchema: {
11219
- name: z.string().min(1).describe('Credential name, as listed by list_credentials'),
11334
+ name: z
11335
+ .string()
11336
+ .min(1)
11337
+ .optional()
11338
+ .describe('Credential name, as listed by list_credentials'),
11339
+ id: z
11340
+ .string()
11341
+ .optional()
11342
+ .describe('Credential id, as pasted by the web app\'s "Copy for agent" button. Use it instead of ' +
11343
+ 'name; it names exactly one credential, where a name can be ambiguous.'),
11220
11344
  },
11221
11345
  }, async (args) => {
11222
11346
  touchSession(connectionId);
@@ -11250,7 +11374,12 @@ runTodoIdSource = null) {
11250
11374
  'about to do. If two organizations hold a skill of the same name this refuses rather than ' +
11251
11375
  'guessing: pass org_id to say which you mean. Read-only.',
11252
11376
  inputSchema: {
11253
- name: z.string().min(1).describe('Skill name, as listed by list_skills'),
11377
+ name: z.string().min(1).optional().describe('Skill name, as listed by list_skills'),
11378
+ id: z
11379
+ .string()
11380
+ .optional()
11381
+ .describe('Skill id, as pasted by the web app\'s "Copy for agent" button. Use it instead of name; ' +
11382
+ 'it names exactly one skill, so org_id is never needed with it.'),
11254
11383
  org_id: z
11255
11384
  .string()
11256
11385
  .optional()
@@ -11266,7 +11395,11 @@ runTodoIdSource = null) {
11266
11395
  'those paths here. WHEN A SKILL POINTS AT A FILE BESIDE IT, FETCH IT AND FOLLOW IT — never ask ' +
11267
11396
  'the user to supply a file the organization already stored. Text only, up to 512 KB. Read-only.',
11268
11397
  inputSchema: {
11269
- name: z.string().min(1).describe('Skill name, as listed by list_skills'),
11398
+ name: z.string().min(1).optional().describe('Skill name, as listed by list_skills'),
11399
+ id: z
11400
+ .string()
11401
+ .optional()
11402
+ .describe('Skill id. Use it instead of name; it names exactly one skill.'),
11270
11403
  path: z
11271
11404
  .string()
11272
11405
  .min(1)
@@ -11313,6 +11446,22 @@ runTodoIdSource = null) {
11313
11446
  touchSession(connectionId);
11314
11447
  return getProjectContextHandler(client, openSessions.get(connectionId) ?? null, args);
11315
11448
  });
11449
+ server.registerTool('get_document', {
11450
+ description: 'Read ONE context document or agent instruction by its id — the whole document, not an excerpt. ' +
11451
+ 'Use it when someone hands you an id (the web app’s “Copy for agent” button pastes one), or when ' +
11452
+ 'get_project_context named a document you need in full. It resolves either kind, so you do not ' +
11453
+ 'need to know which one the id belongs to. To read a project’s context as a whole instead, call ' +
11454
+ 'get_project_context. Read-only.',
11455
+ inputSchema: {
11456
+ id: z
11457
+ .string()
11458
+ .min(1)
11459
+ .describe('Document id, as the web app’s “Copy for agent” button pastes it'),
11460
+ },
11461
+ }, async (args) => {
11462
+ touchSession(connectionId);
11463
+ return getDocumentHandler(client, args);
11464
+ });
11316
11465
  server.registerTool('propose_project_context', {
11317
11466
  // The description TEACHES: when to reach for this (after reading a repo),
11318
11467
  // what a good proposal is (content it actually read, one line of why),
@@ -13135,7 +13284,7 @@ let foreignProbeCache = null;
13135
13284
  /** Whether ANOTHER process's ctrl-spc tools server answers /health on the fixed
13136
13285
  * port. Never throws; refusals, timeouts, and non-ctrl-spc answers all read as
13137
13286
  * "not alive" (→ the badge keeps today's failed semantics). */
13138
- async function foreignToolsServerAlive(fetchImpl) {
13287
+ export async function foreignToolsServerAlive(fetchImpl = fetch) {
13139
13288
  const now = Date.now();
13140
13289
  if (foreignProbeCache &&
13141
13290
  foreignProbeCache.fetchImpl === fetchImpl &&
@@ -13162,14 +13311,20 @@ async function foreignToolsServerAlive(fetchImpl) {
13162
13311
  * `claude mcp add --scope user` writes) carries a `ctrl-spc` entry whose url
13163
13312
  * matches the CURRENT mcp token and tools port. A stale entry from an old
13164
13313
  * token or port does not count — it would 401 against the live server. */
13165
- function claudeRegisteredOnDisk() {
13314
+ export function claudeRegisteredOnDisk() {
13166
13315
  try {
13316
+ // readMcpToken, never mcpToken: `cs status` must not mint a token as a side
13317
+ // effect of a read. No token on disk means this install never launched, so
13318
+ // nothing can be registered against it either way.
13319
+ const token = readMcpToken();
13320
+ if (!token)
13321
+ return false;
13167
13322
  const raw = readFileSync(join(homedir(), '.claude.json'), 'utf8');
13168
13323
  const parsed = JSON.parse(raw);
13169
13324
  const url = parsed?.mcpServers?.['ctrl-spc']?.url;
13170
13325
  return (typeof url === 'string' &&
13171
13326
  url.includes(`127.0.0.1:${TOOLS_SERVER_PORT}/mcp`) &&
13172
- url.includes(`token=${mcpToken()}`));
13327
+ url.includes(`token=${token}`));
13173
13328
  }
13174
13329
  catch {
13175
13330
  return false;
@@ -13260,11 +13415,14 @@ export function ensureCodexMcpApproval(path = codexConfigPath()) {
13260
13415
  * written (current token + port). A substring check on purpose: it holds
13261
13416
  * regardless of how Codex quotes/normalizes the TOML around it, and the
13262
13417
  * tokened url is unique to this install. */
13263
- function codexRegisteredOnDisk() {
13418
+ export function codexRegisteredOnDisk() {
13264
13419
  try {
13420
+ const token = readMcpToken(); // see claudeRegisteredOnDisk: never mint here
13421
+ if (!token)
13422
+ return false;
13265
13423
  const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex');
13266
13424
  const raw = readFileSync(join(codexHome, 'config.toml'), 'utf8');
13267
- return raw.includes(`http://127.0.0.1:${TOOLS_SERVER_PORT}/mcp?token=${mcpToken()}`);
13425
+ return raw.includes(`http://127.0.0.1:${TOOLS_SERVER_PORT}/mcp?token=${token}`);
13268
13426
  }
13269
13427
  catch {
13270
13428
  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
  }