@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 +1 -1
- package/dist/codex-home.js +10 -6
- package/dist/config.js +17 -0
- package/dist/index.js +93 -12
- package/dist/login.js +9 -1
- package/dist/mcp.js +186 -28
- package/dist/panel3/answer.js +12 -12
- package/dist/panel3/checkout.js +522 -2
- package/dist/panel3/cli.js +49 -62
- package/dist/panel3/client.js +53 -16
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/prompt.js +146 -3
- package/dist/panel3/run.js +638 -71
- package/dist/panel3/say.js +10 -10
- package/dist/panel3/show.js +195 -22
- package/dist/panel3/spawn.js +5 -2
- package/dist/panel3/tools.js +326 -5
- package/dist/presence.js +8 -0
- package/dist/skills.js +174 -0
- package/package.json +2 -3
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
|
|
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
|
package/dist/codex-home.js
CHANGED
|
@@ -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
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* the
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
|
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
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
console.log(
|
|
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){
|
|
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,
|
|
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
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
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
|
-
|
|
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
|
|
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=${
|
|
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=${
|
|
13425
|
+
return raw.includes(`http://127.0.0.1:${TOOLS_SERVER_PORT}/mcp?token=${token}`);
|
|
13268
13426
|
}
|
|
13269
13427
|
catch {
|
|
13270
13428
|
return false;
|
package/dist/panel3/answer.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ═══ AGENT PANEL v3: `
|
|
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 `
|
|
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
|
-
* `
|
|
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:
|
|
51
|
+
const USAGE = 'usage: cs answer <question-id> "<text>"';
|
|
52
52
|
/**
|
|
53
|
-
* The two arguments, and a second bare word refused rather than joined: `
|
|
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
|
-
* `
|
|
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 `
|
|
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}. \`
|
|
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:
|
|
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. `
|
|
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
|
-
`
|
|
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
|
-
`
|
|
164
|
+
` cs say --card ${cardId} "<text>"`,
|
|
165
165
|
];
|
|
166
166
|
}
|