@ctrl-spc/cs 0.3.0 → 0.4.0
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/dist/config.js +14 -1
- package/dist/mcp.js +662 -9
- package/dist/package-version.js +10 -0
- package/dist/presence-heartbeat.js +13 -0
- package/dist/presence.js +6 -6
- package/dist/supabase.js +4 -1
- package/package.json +6 -2
package/dist/config.js
CHANGED
|
@@ -199,7 +199,20 @@ export function writeCodebasePath(identity, localPath) {
|
|
|
199
199
|
* On disk (not per-process) so `cs open` and the resident server agree on it.
|
|
200
200
|
*/
|
|
201
201
|
export function companionToken() {
|
|
202
|
-
|
|
202
|
+
return persistentToken('companion-token');
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Stable per-install MCP tools-server token, same contract as companionToken.
|
|
206
|
+
* The loopback /mcp endpoint requires it, and it is embedded in the URL that
|
|
207
|
+
* registration writes into Claude/Codex configs — so only clients CTRL+SPC
|
|
208
|
+
* registered (or that can read this 0600 file) can reach the tools, not any
|
|
209
|
+
* process that happens to find the loopback port.
|
|
210
|
+
*/
|
|
211
|
+
export function mcpToken() {
|
|
212
|
+
return persistentToken('mcp-token');
|
|
213
|
+
}
|
|
214
|
+
function persistentToken(name) {
|
|
215
|
+
const path = filePath(name);
|
|
203
216
|
if (existsSync(path)) {
|
|
204
217
|
try {
|
|
205
218
|
const token = readFileSync(path, 'utf8').trim();
|
package/dist/mcp.js
CHANGED
|
@@ -8,6 +8,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
|
|
10
10
|
import { agentPath } from './agents.js';
|
|
11
|
+
import { mcpToken, readSession } from './config.js';
|
|
11
12
|
export function attributionFromClientName(name) {
|
|
12
13
|
if (!name)
|
|
13
14
|
return null;
|
|
@@ -82,9 +83,63 @@ async function listTasksHandler(client, args) {
|
|
|
82
83
|
return errorResult(`list_tasks failed: ${err.message}`);
|
|
83
84
|
}
|
|
84
85
|
}
|
|
86
|
+
/** Columns a feedback round exposes to the agent (feature 09, Phase 5) — the
|
|
87
|
+
* web writes `artifact_feedback` rows (cumulative review rounds, each tagged
|
|
88
|
+
* with the presentation's primary artifact and its revision at send time); the
|
|
89
|
+
* CLI reads them through RLS `artifact_feedback_select`
|
|
90
|
+
* (`app.can_access_task`). Round BODIES stay web-written and immutable; the
|
|
91
|
+
* ONE thing the CLI writes back is resolution state, via `resolve_feedback`
|
|
92
|
+
* (hybrid resolution — the DB's status-only guard rejects any other change).
|
|
93
|
+
* status ('open' | 'resolved' | 'reopened'), resolved_in_revision (the
|
|
94
|
+
* revision the resolver claims addressed the round), and status_changed_at
|
|
95
|
+
* make each round's state obvious: 'open' / 'reopened' rounds need attention,
|
|
96
|
+
* 'resolved' ones don't. */
|
|
97
|
+
const FEEDBACK_BASE_COLUMNS = 'id,artifact_id,artifact_revision,body,created_at,created_by';
|
|
98
|
+
const FEEDBACK_COLUMNS = `${FEEDBACK_BASE_COLUMNS},status,resolved_in_revision,status_changed_at`;
|
|
99
|
+
/** True when a PostgREST/Postgres error means "column does not exist"
|
|
100
|
+
* (SQLSTATE 42703) — the shape a database that predates migration
|
|
101
|
+
* 20260724210000 returns for the resolution columns. Detected narrowly (code
|
|
102
|
+
* or message) so every other failure keeps surfacing as a real error. */
|
|
103
|
+
function isUndefinedColumnError(error) {
|
|
104
|
+
return error.code === '42703' || /column .* does not exist/i.test(error.message);
|
|
105
|
+
}
|
|
85
106
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
107
|
+
* Fetch a task's feedback rounds, degrading gracefully against a database that
|
|
108
|
+
* predates migration 20260724210000 (the published CLI is versioned
|
|
109
|
+
* independently of DB migrations): if selecting the resolution columns fails
|
|
110
|
+
* with an undefined-column error, retry with the pre-resolution column list
|
|
111
|
+
* and default every round to status 'open' with null resolution stamps —
|
|
112
|
+
* mirroring the web's degrade posture — instead of letting the whole get_task
|
|
113
|
+
* die. Any OTHER error still throws, so genuinely failed queries surface
|
|
114
|
+
* through get_task's normal error path.
|
|
115
|
+
*/
|
|
116
|
+
async function fetchFeedbackRounds(client, taskId) {
|
|
117
|
+
const roundsQuery = (columns) => client
|
|
118
|
+
.from('artifact_feedback')
|
|
119
|
+
.select(columns)
|
|
120
|
+
.eq('task_id', taskId)
|
|
121
|
+
.order('created_at', { ascending: true })
|
|
122
|
+
.order('id', { ascending: true });
|
|
123
|
+
const { data, error } = await roundsQuery(FEEDBACK_COLUMNS);
|
|
124
|
+
if (!error)
|
|
125
|
+
return data ?? [];
|
|
126
|
+
if (!isUndefinedColumnError(error))
|
|
127
|
+
throw new Error(error.message);
|
|
128
|
+
const rows = await must(roundsQuery(FEEDBACK_BASE_COLUMNS));
|
|
129
|
+
return (rows ?? []).map((row) => ({
|
|
130
|
+
...row,
|
|
131
|
+
status: 'open',
|
|
132
|
+
resolved_in_revision: null,
|
|
133
|
+
status_changed_at: null,
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Read one task by id (RLS-scoped) with its tags, comments, live artifacts,
|
|
138
|
+
* decisions (D3 — so the agent can read the answers to questions it asked), and
|
|
139
|
+
* feedback rounds (feature 09, Phase 5 — the cumulative wireframe-review rounds
|
|
140
|
+
* the user sends from the web, each tagged with the presented primary artifact
|
|
141
|
+
* and its revision at send time; per-note option attribution is inline in the
|
|
142
|
+
* body).
|
|
88
143
|
* Adapted from v1's `getTaskHandler` for the column shapes, with every
|
|
89
144
|
* topology / checkout / coordination section dropped.
|
|
90
145
|
*/
|
|
@@ -98,7 +153,7 @@ async function getTaskHandler(client, args) {
|
|
|
98
153
|
.maybeSingle());
|
|
99
154
|
if (!row)
|
|
100
155
|
return errorResult(`No task found for id "${args.id}".`);
|
|
101
|
-
const [comments, artifacts, decisions] = await Promise.all([
|
|
156
|
+
const [comments, artifacts, decisions, feedback] = await Promise.all([
|
|
102
157
|
must(client
|
|
103
158
|
.from('comments')
|
|
104
159
|
.select('id,task_id,body,author_id,from_agent,agent_run_id,created_at,updated_at')
|
|
@@ -120,6 +175,17 @@ async function getTaskHandler(client, args) {
|
|
|
120
175
|
.eq('task_id', row.id)
|
|
121
176
|
.order('asked_at', { ascending: true })
|
|
122
177
|
.order('id', { ascending: true })),
|
|
178
|
+
// Feature 09 Phase 5: the item's feedback rounds — cumulative review
|
|
179
|
+
// rounds the user sent on presented artifacts (bodies web-written and
|
|
180
|
+
// immutable), oldest first, each tagged with the presentation's primary
|
|
181
|
+
// artifact and its revision at send time (the presentation's version
|
|
182
|
+
// stamp; option attribution is inline in the round's body). Each carries
|
|
183
|
+
// its resolution state (status / resolved_in_revision /
|
|
184
|
+
// status_changed_at) so the agent can tell which rounds still need
|
|
185
|
+
// attention ('open' / 'reopened') and which don't ('resolved'). Against
|
|
186
|
+
// a pre-20260724210000 database the helper degrades to the base columns
|
|
187
|
+
// with defaulted resolution state rather than failing the whole read.
|
|
188
|
+
fetchFeedbackRounds(client, row.id),
|
|
123
189
|
]);
|
|
124
190
|
const { task_tags: _drop, ...task } = row;
|
|
125
191
|
return textResult({
|
|
@@ -127,6 +193,7 @@ async function getTaskHandler(client, args) {
|
|
|
127
193
|
comments: comments ?? [],
|
|
128
194
|
artifacts: artifacts ?? [],
|
|
129
195
|
decisions: decisions ?? [],
|
|
196
|
+
feedback: feedback ?? [],
|
|
130
197
|
});
|
|
131
198
|
}
|
|
132
199
|
catch (err) {
|
|
@@ -153,6 +220,17 @@ async function createArtifactHandler(client, userId, currentSession, args) {
|
|
|
153
220
|
if (args.purpose_key?.trim().startsWith('context:')) {
|
|
154
221
|
return errorResult('Context is a reserved work-item artifact.');
|
|
155
222
|
}
|
|
223
|
+
// The 'wireframe_option:' namespace is reserved exactly like 'context:'
|
|
224
|
+
// (feature 09, Phase 2): the web suppresses idx > 0 wireframe_option
|
|
225
|
+
// artifacts from the Artifacts list, so an agent re-using a purpose_key it
|
|
226
|
+
// saw in a present_wireframes / present_mocks result could silently hide an
|
|
227
|
+
// ordinary artifact. Mocks share the namespace deliberately (feature 10),
|
|
228
|
+
// so this ONE reservation covers both tools.
|
|
229
|
+
// update_artifact cannot set purpose_key at all (its patch is
|
|
230
|
+
// built only from title/type/format/content), so INSERT is the only door.
|
|
231
|
+
if (args.purpose_key?.trim().startsWith('wireframe_option:')) {
|
|
232
|
+
return errorResult("The purpose_key prefix 'wireframe_option:' is reserved for present_wireframes / present_mocks presentations.");
|
|
233
|
+
}
|
|
156
234
|
const task = await must(client.from('tasks').select('id').eq('id', args.task_id).is('archived_at', null).maybeSingle());
|
|
157
235
|
if (!task)
|
|
158
236
|
return errorResult(`No task found for id "${args.task_id}".`);
|
|
@@ -514,6 +592,429 @@ async function askQuestionHandler(client, userId, session, args) {
|
|
|
514
592
|
return errorResult(`ask_question failed: ${err.message}`);
|
|
515
593
|
}
|
|
516
594
|
}
|
|
595
|
+
/** Option labels the review outcome reserves (feature 09, Phase 2): 'approve'
|
|
596
|
+
* is the single-shape approval sentinel `answer_task_decision_v2`'s machinery
|
|
597
|
+
* keys on to auto-write `artifact_approvals`, and 'request changes' is the
|
|
598
|
+
* iterate outcome appended to EVERY review decision's options. A user-named
|
|
599
|
+
* option matching either (case-insensitively) would collide with the outcome
|
|
600
|
+
* derivation, so validation rejects them. */
|
|
601
|
+
const RESERVED_OPTION_LABELS = ['approve', 'request changes'];
|
|
602
|
+
/**
|
|
603
|
+
* Present 1..N renders to the user for fullscreen review — the composition BOTH
|
|
604
|
+
* review tools run (feature 09-present-wireframes-tool Phase 2, which REPLACED
|
|
605
|
+
* Phase 1's free_text shape; feature 10-present-mocks-tool Phase 1–2). The caller
|
|
606
|
+
* supplies a `spec`; the plumbing below is identical for wireframes and mocks
|
|
607
|
+
* on purpose, so the web's ONE review surface serves both. A COMPOSITION of
|
|
608
|
+
* existing plumbing — no new tables, no migrations, no new RPCs:
|
|
609
|
+
* 1. each render becomes a normal artifact on the SESSION'S task, inserted
|
|
610
|
+
* exactly the way create_artifact does (AS THE USER, `from_agent` null —
|
|
611
|
+
* the deployed guard reserves agent attribution for the coordinated path
|
|
612
|
+
* this feature does not use — plus the same best-effort
|
|
613
|
+
* cliv2_agent_outputs attribution, one row per artifact): type
|
|
614
|
+
* 'wireframe' for a UI diagram, 'diagram' for an architecture diagram,
|
|
615
|
+
* 'mock' for an interactive mock, format 'html'. The multi shape
|
|
616
|
+
* (options: 2..11 of {label, html})
|
|
617
|
+
* creates one artifact per option, in order, and maps option ↔ artifact
|
|
618
|
+
* through purpose_key 'wireframe_option:<presentation-uuid>:<idx>' on
|
|
619
|
+
* EVERY option artifact (idx 0 included) — the web matches decision
|
|
620
|
+
* options[i] to the artifact with idx i. Idx 0 is the PRIMARY: it carries
|
|
621
|
+
* the presentation title verbatim (idx > 0 titles are '<title> — <label>')
|
|
622
|
+
* and the review decision relates to it. The single shape (html) stays
|
|
623
|
+
* exactly Phase 1's one untagged artifact — NO purpose_key.
|
|
624
|
+
* 2. the review request becomes a single_select decision opened through the
|
|
625
|
+
* SAME `cliv2_ask_question` RPC ask_question uses — category
|
|
626
|
+
* 'wireframe_review' is the sentinel the web keys on to render the
|
|
627
|
+
* fullscreen review surface instead of a plain question, the context
|
|
628
|
+
* carries the title, and `p_related_artifact` points at the primary
|
|
629
|
+
* artifact. Options: ['approve', 'request changes'] (single) or
|
|
630
|
+
* [...labels, 'request changes'] (multi). One uniform outcome for 1..N:
|
|
631
|
+
* the user either APPROVES — 'approve' is the exact shape
|
|
632
|
+
* answer_task_decision_v2 keys on to auto-write artifact_approvals;
|
|
633
|
+
* picking an option label IS approving that option (derived in the web) —
|
|
634
|
+
* or requests changes; their comment rides along as answer_note. Hence
|
|
635
|
+
* 'approve' / 'request changes' are RESERVED label values, and the DB's
|
|
636
|
+
* 12-option decision cap bounds the labels at 11.
|
|
637
|
+
* The review loop (Phase 5 — cumulative feedback rounds): the user sends any
|
|
638
|
+
* number of feedback rounds, any time, without waiting — each is a web-written
|
|
639
|
+
* `artifact_feedback` row tagged with the presentation's PRIMARY (idx 0)
|
|
640
|
+
* artifact and its artifact_revision at send time. That tag is the version
|
|
641
|
+
* stamp of the presentation as a whole, NOT a per-option pointer: in a
|
|
642
|
+
* multi-option presentation, which option a note concerns is named inline in
|
|
643
|
+
* the note's "(…)" option label within the round's body. The agent reads the
|
|
644
|
+
* rounds from get_task's `feedback` list (oldest first). The
|
|
645
|
+
* decision above is the APPROVAL GATE only: it stays open across rounds and
|
|
646
|
+
* decides ONLY when the user approves (selected_options[0] 'approve' or an
|
|
647
|
+
* option label; answer_note may carry a final comment). To iterate, the agent
|
|
648
|
+
* revises the presented render IN PLACE with update_artifact — the DB bumps
|
|
649
|
+
* its revision automatically, so later rounds self-identify against the new
|
|
650
|
+
* version — and keeps polling; it must NOT call the presentation tool again for
|
|
651
|
+
* iterations of the same design. Requires an open session (like ask_question —
|
|
652
|
+
* the decision lives on the session's task, so there's no task_id arg). Full
|
|
653
|
+
* version history is a later phase.
|
|
654
|
+
*/
|
|
655
|
+
async function presentationHandler(client, userId, session, args, spec) {
|
|
656
|
+
try {
|
|
657
|
+
if (!session) {
|
|
658
|
+
return errorResult(`${spec.tool} needs an open work session — call begin_work first.`);
|
|
659
|
+
}
|
|
660
|
+
if (!args.title || !args.title.trim())
|
|
661
|
+
return errorResult(`${spec.tool} requires a non-empty title.`);
|
|
662
|
+
const extra = spec.extraValidation?.();
|
|
663
|
+
if (extra)
|
|
664
|
+
return errorResult(extra);
|
|
665
|
+
// Exactly ONE of the two shapes: html (a single render, exactly Phase 1)
|
|
666
|
+
// or options (2..11 competing renders).
|
|
667
|
+
const hasHtml = args.html !== undefined;
|
|
668
|
+
const hasOptions = args.options !== undefined;
|
|
669
|
+
if (hasHtml && hasOptions) {
|
|
670
|
+
return errorResult(`${spec.tool} takes either html (one ${spec.unit}) or options (2..11 competing ${spec.unit}s), not both.`);
|
|
671
|
+
}
|
|
672
|
+
if (!hasHtml && !hasOptions) {
|
|
673
|
+
return errorResult(`${spec.tool} requires html (one ${spec.unit}) or options (2..11 competing ${spec.unit}s).`);
|
|
674
|
+
}
|
|
675
|
+
const multi = hasOptions;
|
|
676
|
+
let opts = [];
|
|
677
|
+
if (multi) {
|
|
678
|
+
if (!Array.isArray(args.options) || args.options.length < 2 || args.options.length > 11) {
|
|
679
|
+
return errorResult(`${spec.tool} requires 2..11 options (use html for a single ${spec.unit}; a review decision is capped at 12 options including the reserved one).`);
|
|
680
|
+
}
|
|
681
|
+
opts = args.options.map((option) => ({ label: (option.label ?? '').trim(), html: option.html ?? '' }));
|
|
682
|
+
if (opts.some((o) => !o.label)) {
|
|
683
|
+
return errorResult(`${spec.tool} requires a non-empty label for every option.`);
|
|
684
|
+
}
|
|
685
|
+
if (opts.some((o) => !o.html.trim())) {
|
|
686
|
+
return errorResult(`${spec.tool} requires non-empty html for every option (${spec.htmlSpec}).`);
|
|
687
|
+
}
|
|
688
|
+
const lowered = opts.map((o) => o.label.toLowerCase());
|
|
689
|
+
if (new Set(lowered).size !== lowered.length) {
|
|
690
|
+
return errorResult(`${spec.tool} requires distinct option labels (case-insensitive).`);
|
|
691
|
+
}
|
|
692
|
+
if (lowered.some((label) => RESERVED_OPTION_LABELS.includes(label))) {
|
|
693
|
+
return errorResult(`${spec.tool} option labels 'approve' and 'request changes' are reserved review outcomes — rename the option.`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
else if (!args.html || !args.html.trim()) {
|
|
697
|
+
return errorResult(`${spec.tool} requires non-empty html (${spec.htmlSpec}).`);
|
|
698
|
+
}
|
|
699
|
+
const title = args.title.trim();
|
|
700
|
+
// Sentence-initial form of the artifact noun, for the attribution warning.
|
|
701
|
+
const capitalNoun = spec.artifactNoun.charAt(0).toUpperCase() + spec.artifactNoun.slice(1);
|
|
702
|
+
// The review decision's question — the recovery message below quotes it
|
|
703
|
+
// LITERALLY, so both must come from this one string.
|
|
704
|
+
const question = `Review the ${spec.artifactNoun} and leave feedback.`;
|
|
705
|
+
// One entry per artifact to create, in presentation order (idx 0 = the
|
|
706
|
+
// primary). Multi tags EVERY entry with the option ↔ artifact mapping key;
|
|
707
|
+
// single stays untagged, exactly Phase 1.
|
|
708
|
+
const presentationId = multi ? randomUUID() : null;
|
|
709
|
+
const renders = multi
|
|
710
|
+
? opts.map((option, idx) => ({
|
|
711
|
+
title: idx === 0 ? title : `${title} — ${option.label}`,
|
|
712
|
+
html: option.html,
|
|
713
|
+
purpose_key: `wireframe_option:${presentationId}:${idx}`,
|
|
714
|
+
}))
|
|
715
|
+
: [{ title, html: args.html, purpose_key: null }];
|
|
716
|
+
// The review decision's options: the approval outcome(s) first, 'request
|
|
717
|
+
// changes' always last (the DB caps decision options at 12, hence ≤11 labels).
|
|
718
|
+
const reviewOptions = multi ? [...opts.map((o) => o.label), 'request changes'] : ['approve', 'request changes'];
|
|
719
|
+
// The renders land on the session's task. Confirm it is live under the
|
|
720
|
+
// user's RLS first (mirrors create_artifact) so a task archived after
|
|
721
|
+
// begin_work gets a clean error instead of an opaque insert failure.
|
|
722
|
+
const task = await must(client.from('tasks').select('id').eq('id', session.taskId).is('archived_at', null).maybeSingle());
|
|
723
|
+
if (!task)
|
|
724
|
+
return errorResult(`No task found for id "${session.taskId}".`);
|
|
725
|
+
// 1. Each render as a normal artifact — the SAME insert create_artifact
|
|
726
|
+
// does, one row per render, in presentation order.
|
|
727
|
+
const created = [];
|
|
728
|
+
const attributionWarnings = [];
|
|
729
|
+
for (const render of renders) {
|
|
730
|
+
let row = null;
|
|
731
|
+
try {
|
|
732
|
+
row = await must(client
|
|
733
|
+
.from('artifacts')
|
|
734
|
+
.insert({
|
|
735
|
+
task_id: session.taskId,
|
|
736
|
+
type: spec.artifactType,
|
|
737
|
+
format: 'html',
|
|
738
|
+
title: render.title,
|
|
739
|
+
...(render.purpose_key ? { purpose_key: render.purpose_key } : {}),
|
|
740
|
+
content: render.html,
|
|
741
|
+
created_by: userId,
|
|
742
|
+
from_agent: null,
|
|
743
|
+
agent_run_id: null,
|
|
744
|
+
})
|
|
745
|
+
.select(ARTIFACT_COLUMNS)
|
|
746
|
+
.single());
|
|
747
|
+
if (!row)
|
|
748
|
+
throw new Error('Artifact insert returned no row.');
|
|
749
|
+
}
|
|
750
|
+
catch (err) {
|
|
751
|
+
// Nothing created yet → fatal and generic, exactly the Phase 1 path.
|
|
752
|
+
if (created.length === 0)
|
|
753
|
+
throw err;
|
|
754
|
+
// A later option insert failing orphans the already-created options:
|
|
755
|
+
// return them (ids included) with guidance the agent can actually act
|
|
756
|
+
// on. No tool can delete an artifact (update_artifact has no deleted_at
|
|
757
|
+
// and there is no delete tool), so "clean up" is not an instruction the
|
|
758
|
+
// agent can execute — removal is a manual web action, and the agent's
|
|
759
|
+
// job is to surface it and hold off re-presenting (a blind retry would
|
|
760
|
+
// duplicate the created options).
|
|
761
|
+
const createdIds = created.map((row) => row.id).join(', ');
|
|
762
|
+
const failure = {
|
|
763
|
+
artifacts: created,
|
|
764
|
+
error: `${spec.tool}: created ${created.length} of ${renders.length} option artifacts, then the insert for ` +
|
|
765
|
+
`'${render.title}' failed: ${err.message}. No review request was opened. There is no delete ` +
|
|
766
|
+
`tool: the created artifacts (${createdIds}) are inert until removed manually in the web. Report this ` +
|
|
767
|
+
`failure to the user with those ids, and do NOT call ${spec.tool} again (it would duplicate the ` +
|
|
768
|
+
'created options) until the user confirms.',
|
|
769
|
+
};
|
|
770
|
+
if (attributionWarnings.length)
|
|
771
|
+
failure.attribution_warning = attributionWarnings.join(' ');
|
|
772
|
+
return { ...textResult(failure), isError: true };
|
|
773
|
+
}
|
|
774
|
+
created.push(row);
|
|
775
|
+
// Best-effort session attribution per artifact (mirrors create_artifact:
|
|
776
|
+
// the session's task matches by construction). The artifact already exists
|
|
777
|
+
// and is part of the real return value, so a failed attribution write must
|
|
778
|
+
// NOT fail the tool — it is surfaced in the returned text, never dropped.
|
|
779
|
+
try {
|
|
780
|
+
await must(client.from('cliv2_agent_outputs').insert({
|
|
781
|
+
user_id: userId,
|
|
782
|
+
session_id: session.sessionId,
|
|
783
|
+
kind: 'artifact',
|
|
784
|
+
product_id: row.id,
|
|
785
|
+
}));
|
|
786
|
+
}
|
|
787
|
+
catch (err) {
|
|
788
|
+
attributionWarnings.push(`${capitalNoun} artifact created, but recording session attribution failed: ${err.message}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const primary = created[0];
|
|
792
|
+
// 2. The review request as a single_select decision — the SAME RPC
|
|
793
|
+
// ask_question uses; 'wireframe_review' is the sentinel category the web
|
|
794
|
+
// keys on (feature 09). The artifacts above already exist, so from here a
|
|
795
|
+
// failure must NOT be the generic fatal error: swallowing them would orphan
|
|
796
|
+
// them in the DB (plain cards with no "feedback requested"), leave the
|
|
797
|
+
// agent with no ids to recover with, and invite a blind retry that
|
|
798
|
+
// duplicates artifacts. So this step gets its OWN try/catch (the FIRST
|
|
799
|
+
// artifact-insert failure path above stays fatal and generic), and on
|
|
800
|
+
// failure the error result STILL carries the created artifact(s) plus a
|
|
801
|
+
// message telling the agent to retry the review step — via ask_question
|
|
802
|
+
// with related_artifact_id — or clean up, not re-present.
|
|
803
|
+
let decision;
|
|
804
|
+
try {
|
|
805
|
+
const decisionId = await must(client.rpc('cliv2_ask_question', {
|
|
806
|
+
p_session: session.sessionId,
|
|
807
|
+
p_category: 'wireframe_review',
|
|
808
|
+
p_context: title,
|
|
809
|
+
p_question: question,
|
|
810
|
+
p_answer_mode: 'single_select',
|
|
811
|
+
p_options: reviewOptions,
|
|
812
|
+
p_related_artifact: primary.id,
|
|
813
|
+
}));
|
|
814
|
+
if (!decisionId)
|
|
815
|
+
throw new Error('Review request insert returned no decision id.');
|
|
816
|
+
// Best-effort attribution for the decision (mirrors ask_question exactly).
|
|
817
|
+
try {
|
|
818
|
+
await must(client.from('cliv2_agent_outputs').insert({
|
|
819
|
+
user_id: userId,
|
|
820
|
+
session_id: session.sessionId,
|
|
821
|
+
kind: 'decision',
|
|
822
|
+
product_id: decisionId,
|
|
823
|
+
}));
|
|
824
|
+
}
|
|
825
|
+
catch (err) {
|
|
826
|
+
attributionWarnings.push(`Review request created, but recording session attribution failed: ${err.message}`);
|
|
827
|
+
}
|
|
828
|
+
// Re-fetch the created decision with the SAME columns get_task returns, so
|
|
829
|
+
// the agent sees what it made and can later read the feedback back with
|
|
830
|
+
// get_task.
|
|
831
|
+
const fetched = await must(client.from('decisions').select(DECISION_COLUMNS).eq('id', decisionId).maybeSingle());
|
|
832
|
+
if (!fetched)
|
|
833
|
+
throw new Error('Created review decision could not be re-fetched.');
|
|
834
|
+
decision = fetched;
|
|
835
|
+
}
|
|
836
|
+
catch (err) {
|
|
837
|
+
// The recovery instruction must be LITERAL and complete: the web's review
|
|
838
|
+
// surface matches ONLY category === 'wireframe_review' (a sentinel that is
|
|
839
|
+
// deliberately not in any tool description, and that mocks reuse verbatim
|
|
840
|
+
// — there is no 'mock_review'), and the decision-consistency
|
|
841
|
+
// guard makes category immutable — so a plausible-but-wrong retry category
|
|
842
|
+
// would leave the review permanently stuck as a generic question. Spell out
|
|
843
|
+
// the exact ask_question call, with the real title, the full literal
|
|
844
|
+
// options array, and the primary artifact id. The multi failure payload
|
|
845
|
+
// carries ALL created artifacts (ids included).
|
|
846
|
+
const optionsLiteral = `[${reviewOptions.map((label) => `'${label}'`).join(', ')}]`;
|
|
847
|
+
const failure = {
|
|
848
|
+
...(multi ? { artifacts: created } : { artifact: primary }),
|
|
849
|
+
error: `${spec.tool}: the ${spec.artifactNoun} artifact${multi ? 's were' : ' was'} created, but opening the review request failed: ` +
|
|
850
|
+
`${err.message}. Do NOT call ${spec.tool} again (it would duplicate the artifact${multi ? 's' : ''}) — ` +
|
|
851
|
+
`retry the review step with ask_question({ category: 'wireframe_review', context: '${title}', ` +
|
|
852
|
+
`question: '${question}', answer_mode: 'single_select', ` +
|
|
853
|
+
`options: ${optionsLiteral}, related_artifact_id: '${primary.id}' }) — the category string must be exactly ` +
|
|
854
|
+
"'wireframe_review' or the web will not show the review UI — or clean up.",
|
|
855
|
+
};
|
|
856
|
+
if (attributionWarnings.length)
|
|
857
|
+
failure.attribution_warning = attributionWarnings.join(' ');
|
|
858
|
+
return { ...textResult(failure), isError: true };
|
|
859
|
+
}
|
|
860
|
+
const payload = {
|
|
861
|
+
...(multi ? { artifacts: created } : { artifact: primary }),
|
|
862
|
+
decision,
|
|
863
|
+
instruction: multi
|
|
864
|
+
? `The user has been asked to review ${created.length} competing options. They send CUMULATIVE feedback ` +
|
|
865
|
+
"rounds — any number, any time, without waiting for you: poll get_task and read them from its `feedback` " +
|
|
866
|
+
'list (oldest first). Every round is tagged with the presentation PRIMARY artifact (the first option) and ' +
|
|
867
|
+
'its artifact_revision at send time — the version stamp of the presentation, NOT a per-option pointer. ' +
|
|
868
|
+
"Which OPTION a note concerns is named inline in the note's \"(…)\" option label within the round's body. " +
|
|
869
|
+
'This decision is the approval gate only: it stays open across rounds and decides ONLY when the user ' +
|
|
870
|
+
'approves — once its state is decided, selected_options[0] is the approved option label and answer_note ' +
|
|
871
|
+
'may carry a final comment. To act on feedback, revise the option artifact each note names IN PLACE with ' +
|
|
872
|
+
'update_artifact (its revision bumps automatically), then call resolve_feedback with the ids of the ' +
|
|
873
|
+
"rounds that revision actually addressed and the revised OPTION artifact's NEW revision number — only " +
|
|
874
|
+
'rounds you genuinely acted on. Option revisions are their own number-space (not the primary v-tags): ' +
|
|
875
|
+
'the review surface shows a multi round as resolved WITHOUT a version number, so name WHICH option you ' +
|
|
876
|
+
'revised in any accompanying update, and never expect the primary revision to move when only options ' +
|
|
877
|
+
"changed. A round the user flips back to 'reopened' is open feedback again: address it on the next pass " +
|
|
878
|
+
`and re-resolve. Keep polling. Do NOT call ${spec.tool} ` +
|
|
879
|
+
'again for iterations of this same design.'
|
|
880
|
+
: `The user has been asked to review the ${spec.artifactNoun}. They send CUMULATIVE feedback rounds — any number, any ` +
|
|
881
|
+
"time, without waiting for you: poll get_task and read them from its `feedback` list (oldest first), each " +
|
|
882
|
+
'tagged with the artifact_revision it critiques so you know which version the notes are about. This ' +
|
|
883
|
+
'decision is the approval gate only: it stays open across rounds and decides ONLY when the user approves ' +
|
|
884
|
+
"— once its state is decided, selected_options[0] is 'approve' and answer_note may carry a final comment. " +
|
|
885
|
+
`To act on feedback, revise this ${spec.artifactNoun} IN PLACE with update_artifact (its revision bumps ` +
|
|
886
|
+
'automatically, so later rounds name the new version), then call resolve_feedback with the ids of the ' +
|
|
887
|
+
'rounds that revision actually addressed and the NEW revision number — only rounds you genuinely acted ' +
|
|
888
|
+
"on. A round the user flips back to 'reopened' is open feedback again: address it on the next pass and " +
|
|
889
|
+
`re-resolve. Keep polling. Do NOT call ${spec.tool} ` +
|
|
890
|
+
'again for iterations of this same design.',
|
|
891
|
+
};
|
|
892
|
+
if (attributionWarnings.length)
|
|
893
|
+
payload.attribution_warning = attributionWarnings.join(' ');
|
|
894
|
+
return textResult(payload);
|
|
895
|
+
}
|
|
896
|
+
catch (err) {
|
|
897
|
+
return errorResult(`${spec.tool} failed: ${err.message}`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* present_wireframes (feature 09) — 1..N lo-fi HTML wireframes (kind 'ui') or
|
|
902
|
+
* architecture diagrams (kind 'architecture'). A thin spec over the shared
|
|
903
|
+
* presentation handler; `kind` is the only thing it adds, and it picks the
|
|
904
|
+
* artifact type. Exported (like badgeReason) so the self-check
|
|
905
|
+
* (test-tools.mjs) can exercise its branching with a stub client, without a
|
|
906
|
+
* live MCP handshake.
|
|
907
|
+
*/
|
|
908
|
+
export async function presentWireframesHandler(client, userId, session, args) {
|
|
909
|
+
return presentationHandler(client, userId, session, args, {
|
|
910
|
+
tool: 'present_wireframes',
|
|
911
|
+
unit: 'diagram',
|
|
912
|
+
htmlSpec: 'a self-contained lo-fi HTML/CSS diagram',
|
|
913
|
+
artifactType: args.kind === 'ui' ? 'wireframe' : 'diagram',
|
|
914
|
+
artifactNoun: 'wireframe',
|
|
915
|
+
extraValidation: () => args.kind !== 'ui' && args.kind !== 'architecture'
|
|
916
|
+
? "present_wireframes requires kind to be 'ui' or 'architecture'."
|
|
917
|
+
: null,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* present_mocks (feature 10-present-mocks-tool) — the mocks sibling of
|
|
922
|
+
* present_wireframes: 1..N SELF-CONTAINED INTERACTIVE mocks (HTML + CSS + JS in
|
|
923
|
+
* one document) that actually work — tabs switch, inputs type, buttons act. No
|
|
924
|
+
* `kind`: a mock IS the kind, and the artifact type is always 'mock' /
|
|
925
|
+
* format 'html'.
|
|
926
|
+
*
|
|
927
|
+
* It reuses the wireframe review plumbing WHOLESALE and on purpose — same
|
|
928
|
+
* 'wireframe_review' decision sentinel, same 'wireframe_option:' purpose_key
|
|
929
|
+
* namespace, same reserved 'approve' / 'request changes' labels, same 2..11
|
|
930
|
+
* cap, same open-session requirement, same partial-failure recovery. That is
|
|
931
|
+
* what lets the web's existing review surface (cover, Feedback drawer, rounds,
|
|
932
|
+
* resolution, approve) serve mocks with no new sentinel, no new reservation and
|
|
933
|
+
* no parallel implementation. The ONE thing the web adds for mocks is the
|
|
934
|
+
* FREEZE: while the user is in Feedback mode the injected annotate script
|
|
935
|
+
* swallows clicks/keys so they select elements to annotate instead of driving
|
|
936
|
+
* the mock (scrolling stays live) — which is why the tool description insists
|
|
937
|
+
* the mock be meaningful in its DEFAULT state.
|
|
938
|
+
*
|
|
939
|
+
* SCOPE (Phase 2): competing mocks are live. Phase 1 shipped one mock only and
|
|
940
|
+
* refused `options` here; Phase 2 lifted that guard, so a mocks presentation is
|
|
941
|
+
* now exactly a wireframes presentation with the mock nouns — one call = one
|
|
942
|
+
* presentation of 1..N options, and the user picks by approving the option on
|
|
943
|
+
* screen. The registered schema declares the shape but constrains none of it
|
|
944
|
+
* (see the comment at registerTool), so EVERY refusal below is reachable over
|
|
945
|
+
* MCP and the agent reads the written message rather than a raw zod "Too
|
|
946
|
+
* small". present_wireframes keeps its constrained schema — feature 09's
|
|
947
|
+
* shipped surface — so the two now differ there and only there.
|
|
948
|
+
*/
|
|
949
|
+
export async function presentMocksHandler(client, userId, session, args) {
|
|
950
|
+
return presentationHandler(client, userId, session, args, {
|
|
951
|
+
tool: 'present_mocks',
|
|
952
|
+
unit: 'mock',
|
|
953
|
+
htmlSpec: 'a self-contained interactive HTML/CSS/JS mock',
|
|
954
|
+
artifactType: 'mock',
|
|
955
|
+
artifactNoun: 'mock',
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
/** Loose UUID shape check (any version), so a malformed id gets a clean tool
|
|
959
|
+
* error naming it instead of a Postgres `invalid input syntax for type uuid`
|
|
960
|
+
* failing the whole batch. */
|
|
961
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
962
|
+
/**
|
|
963
|
+
* Mark feedback rounds resolved by a revision (feature 09, Phase 5 — hybrid
|
|
964
|
+
* resolution). After update_artifact ships a revision, the agent calls this
|
|
965
|
+
* with the ids of the rounds that revision actually addressed and the NEW
|
|
966
|
+
* revision number; each round flips to status 'resolved' with
|
|
967
|
+
* resolved_in_revision = revision. The user can flip a round back to
|
|
968
|
+
* 'reopened' in the web when the change wasn't material, and this same call
|
|
969
|
+
* re-resolves it on the next pass. This is the ONE write the CLI makes to
|
|
970
|
+
* `artifact_feedback`: a plain UPDATE as the user through RLS
|
|
971
|
+
* `artifact_feedback_update` (task access + the 'comment' grant) and the
|
|
972
|
+
* column-level grant on the four resolution columns. The DB's status-only
|
|
973
|
+
* guard keeps round bodies immutable and stamps status_changed_at/_by itself
|
|
974
|
+
* (never trusted from here), so only status + resolved_in_revision are sent.
|
|
975
|
+
*
|
|
976
|
+
* The UPDATE's `.in(...)` silently skips ids that don't exist, aren't visible
|
|
977
|
+
* under RLS, or belong to a task the user can't touch — so the result rows are
|
|
978
|
+
* compared against the request and ANY shortfall is an error that lists the
|
|
979
|
+
* missing ids (and the ones that DID update, since those flips committed).
|
|
980
|
+
* Requires an open session, like the sibling review tools. Exported (like
|
|
981
|
+
* presentWireframesHandler) so the self-check can exercise it with a stub
|
|
982
|
+
* client.
|
|
983
|
+
*/
|
|
984
|
+
export async function resolveFeedbackHandler(client, session, args) {
|
|
985
|
+
try {
|
|
986
|
+
if (!session) {
|
|
987
|
+
return errorResult('resolve_feedback needs an open work session — call begin_work first.');
|
|
988
|
+
}
|
|
989
|
+
if (!Array.isArray(args.feedback_ids) || args.feedback_ids.length === 0) {
|
|
990
|
+
return errorResult('resolve_feedback requires feedback_ids (at least one feedback round id).');
|
|
991
|
+
}
|
|
992
|
+
if (!Number.isInteger(args.revision) || args.revision < 1) {
|
|
993
|
+
return errorResult('resolve_feedback requires revision (a positive integer — the artifact revision that addressed the rounds).');
|
|
994
|
+
}
|
|
995
|
+
const ids = [...new Set(args.feedback_ids.map((id) => id.trim()))];
|
|
996
|
+
const malformed = ids.filter((id) => !UUID_RE.test(id));
|
|
997
|
+
if (malformed.length) {
|
|
998
|
+
return errorResult(`resolve_feedback: not valid feedback round id(s): ${malformed.join(', ')}.`);
|
|
999
|
+
}
|
|
1000
|
+
const rows = (await must(client
|
|
1001
|
+
.from('artifact_feedback')
|
|
1002
|
+
.update({ status: 'resolved', resolved_in_revision: args.revision })
|
|
1003
|
+
.in('id', ids)
|
|
1004
|
+
.select('id'))) ?? [];
|
|
1005
|
+
const updated = rows.map((row) => row.id);
|
|
1006
|
+
if (updated.length !== ids.length) {
|
|
1007
|
+
const missing = ids.filter((id) => !updated.includes(id));
|
|
1008
|
+
return errorResult(`resolve_feedback: ${updated.length} of ${ids.length} round(s) updated. Not found (or not accessible): ` +
|
|
1009
|
+
`${missing.join(', ')}.${updated.length ? ` Already marked resolved: ${updated.join(', ')}.` : ''} ` +
|
|
1010
|
+
'Check the ids against the most recent get_task `feedback` list.');
|
|
1011
|
+
}
|
|
1012
|
+
return textResult({ resolved: updated, revision: args.revision });
|
|
1013
|
+
}
|
|
1014
|
+
catch (err) {
|
|
1015
|
+
return errorResult(`resolve_feedback failed: ${err.message}`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
517
1018
|
/**
|
|
518
1019
|
* Record the user's answer to an OPEN decision on the work item (feature 05 / D4 —
|
|
519
1020
|
* the run-free twin of v1's `record_user_input`). Unlike v1, this does NOT go through a
|
|
@@ -913,7 +1414,7 @@ async function getCredentialHandler(client, args) {
|
|
|
913
1414
|
}
|
|
914
1415
|
}
|
|
915
1416
|
// ---------------------------------------------------------------------------
|
|
916
|
-
// The
|
|
1417
|
+
// The twenty-two tools this server exposes. Exported for the self-check.
|
|
917
1418
|
// ---------------------------------------------------------------------------
|
|
918
1419
|
export const TOOL_NAMES = [
|
|
919
1420
|
'list_tasks',
|
|
@@ -927,6 +1428,9 @@ export const TOOL_NAMES = [
|
|
|
927
1428
|
'begin_work',
|
|
928
1429
|
'end_work',
|
|
929
1430
|
'ask_question',
|
|
1431
|
+
'present_wireframes',
|
|
1432
|
+
'present_mocks',
|
|
1433
|
+
'resolve_feedback',
|
|
930
1434
|
'record_user_input',
|
|
931
1435
|
'record_context_exploration',
|
|
932
1436
|
'reserve_work_paths',
|
|
@@ -936,7 +1440,7 @@ export const TOOL_NAMES = [
|
|
|
936
1440
|
'list_credentials',
|
|
937
1441
|
'get_credential',
|
|
938
1442
|
];
|
|
939
|
-
/** Build a per-session McpServer with the
|
|
1443
|
+
/** Build a per-session McpServer with the twenty-two tools. A fresh instance per
|
|
940
1444
|
* session is what makes `server.server.getClientVersion()` (populated during
|
|
941
1445
|
* that session's `initialize`) the right source for attribution — mirroring
|
|
942
1446
|
* v1's per-session `buildServer`. `connectionId` is this connection's key into
|
|
@@ -961,8 +1465,14 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
961
1465
|
return listTasksHandler(client, args);
|
|
962
1466
|
});
|
|
963
1467
|
server.registerTool('get_task', {
|
|
964
|
-
description: 'Read a full task — its tags, comments, artifacts,
|
|
965
|
-
'you asked)
|
|
1468
|
+
description: 'Read a full task — its tags, comments, artifacts, decisions (so you can read the answers to questions ' +
|
|
1469
|
+
'you asked), and feedback (the cumulative wireframe-review rounds the user sent, each tagged with the ' +
|
|
1470
|
+
"presented primary artifact and its artifact_revision at send time — the presentation's version stamp). " +
|
|
1471
|
+
"Each feedback round carries its resolution state: rounds with status 'open' or 'reopened' need your " +
|
|
1472
|
+
"attention ('reopened' means the user flipped a round you resolved back to not resolved — treat it as " +
|
|
1473
|
+
"open feedback on your next pass); 'resolved' rounds don't (resolved_in_revision names the revision that " +
|
|
1474
|
+
'addressed them; mark rounds resolved with resolve_feedback after revising). ' +
|
|
1475
|
+
'Read-only; it has no status or presence side effects.',
|
|
966
1476
|
inputSchema: { id: z.string().describe('Task id') },
|
|
967
1477
|
}, async ({ id }) => {
|
|
968
1478
|
touchSession(connectionId);
|
|
@@ -1178,6 +1688,132 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1178
1688
|
touchSession(connectionId);
|
|
1179
1689
|
return askQuestionHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1180
1690
|
});
|
|
1691
|
+
server.registerTool('present_wireframes', {
|
|
1692
|
+
description: 'Present lo-fi HTML wireframes (kind ui) or architecture diagrams (kind architecture) to the user for ' +
|
|
1693
|
+
'fullscreen review in the web work item — either ONE diagram (html) or 2..11 competing options ' +
|
|
1694
|
+
'(options: [{label, html}, …]); pass exactly one of html / options. Every html must be a self-contained ' +
|
|
1695
|
+
'lo-fi HTML/CSS diagram. The user then sends CUMULATIVE feedback rounds — any number, any time, without ' +
|
|
1696
|
+
"waiting for you: poll get_task and read them from its `feedback` list. Every round is tagged with the " +
|
|
1697
|
+
"presentation's primary artifact and its artifact_revision at send time — the presentation's version " +
|
|
1698
|
+
'stamp, not a per-option pointer; in a multi-option presentation, which option a note concerns is named ' +
|
|
1699
|
+
'inline in the note\'s "(…)" option label within body, so revise the option artifact the notes name. ' +
|
|
1700
|
+
'The returned review ' +
|
|
1701
|
+
'decision is the approval gate only: it stays open across rounds and decides ONLY when the user approves ' +
|
|
1702
|
+
"(selected_options[0] is 'approve', or the approved option's label — choosing an option is approving it; " +
|
|
1703
|
+
'answer_note may carry a final comment). To act on feedback, revise the wireframe IN PLACE with ' +
|
|
1704
|
+
'update_artifact (the revision bumps automatically), then call resolve_feedback with the ids of the ' +
|
|
1705
|
+
'rounds that revision actually addressed and the NEW revision number — only rounds you genuinely acted ' +
|
|
1706
|
+
"on. The user can flip a round back to 'reopened' when the change wasn't material; treat a reopened " +
|
|
1707
|
+
'round as open feedback on the next pass (resolve_feedback re-resolves it). Keep polling — do NOT call ' +
|
|
1708
|
+
'present_wireframes ' +
|
|
1709
|
+
'again for iterations of the same design. Requires an open session (begin_work). If diagrams are created ' +
|
|
1710
|
+
'but the review request fails, the error includes the created artifacts and exact recovery instructions ' +
|
|
1711
|
+
'— follow them instead of calling this tool again.',
|
|
1712
|
+
inputSchema: {
|
|
1713
|
+
title: z.string().min(1).describe('Short title for the presentation (shown on the work item)'),
|
|
1714
|
+
kind: z.enum(['ui', 'architecture']).describe("'ui' for wireframes, 'architecture' for code/flow diagrams"),
|
|
1715
|
+
html: z
|
|
1716
|
+
.string()
|
|
1717
|
+
.min(1)
|
|
1718
|
+
.optional()
|
|
1719
|
+
.describe('A single self-contained lo-fi HTML/CSS diagram (exactly one of html / options)'),
|
|
1720
|
+
options: z
|
|
1721
|
+
.array(z.object({ label: z.string().min(1), html: z.string().min(1) }))
|
|
1722
|
+
.min(2)
|
|
1723
|
+
.max(11)
|
|
1724
|
+
.optional()
|
|
1725
|
+
.describe('2..11 competing diagrams, each with a short distinct label (exactly one of html / options; ' +
|
|
1726
|
+
"the labels 'approve' and 'request changes' are reserved)"),
|
|
1727
|
+
},
|
|
1728
|
+
}, async (args) => {
|
|
1729
|
+
touchSession(connectionId);
|
|
1730
|
+
return presentWireframesHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1731
|
+
});
|
|
1732
|
+
server.registerTool('present_mocks', {
|
|
1733
|
+
description: 'Present WORKING, INTERACTIVE HTML mocks to the user for fullscreen review in the web work item — ' +
|
|
1734
|
+
'either ONE mock (html) or 2..11 competing options (options: [{label, html}, …]); pass exactly one of ' +
|
|
1735
|
+
'html / options. One call = ONE presentation: the user flips between the options and picks one by ' +
|
|
1736
|
+
'APPROVING the option on screen. ' +
|
|
1737
|
+
'Every html must be a SELF-CONTAINED interactive mock: HTML, CSS and JS all inline in the one ' +
|
|
1738
|
+
'document, with fake in-memory data, so it actually works — tabs switch, inputs type, buttons act. No ' +
|
|
1739
|
+
'external assets, no network (the sandboxed iframe blocks fetch/XHR/CDN links) and no storage ' +
|
|
1740
|
+
'(localStorage/cookies are unavailable) — anything that needs them will silently fail. IMPORTANT: while ' +
|
|
1741
|
+
'the user is in Feedback mode the viewer FREEZES the mock — clicks and keys select elements to annotate ' +
|
|
1742
|
+
'instead of driving it (only scrolling stays live) — so the mock must be readable and meaningful in its ' +
|
|
1743
|
+
'DEFAULT state; never hide the substance behind an interaction the user cannot perform while annotating. ' +
|
|
1744
|
+
'That applies per option: whichever option is on screen is the one that freezes. ' +
|
|
1745
|
+
'The user then sends CUMULATIVE feedback rounds — any number, any time, without waiting for you: poll ' +
|
|
1746
|
+
"get_task and read them from its `feedback` list. Every round is tagged with the presentation's primary " +
|
|
1747
|
+
"artifact and its artifact_revision at send time — the presentation's version stamp, not a per-option " +
|
|
1748
|
+
'pointer; in a multi-option presentation, which option a note concerns is named inline in the ' +
|
|
1749
|
+
'note\'s "(…)" option label within body, so revise the option artifact the notes name. ' +
|
|
1750
|
+
'The returned review ' +
|
|
1751
|
+
'decision is the approval gate only: it stays open across rounds and decides ONLY when the user approves ' +
|
|
1752
|
+
"(selected_options[0] is 'approve', or the approved option's label — choosing an option is approving it; " +
|
|
1753
|
+
'answer_note may carry a final comment). To act on feedback, revise the mock IN PLACE with ' +
|
|
1754
|
+
'update_artifact (the revision bumps automatically), then call resolve_feedback with the ids of the ' +
|
|
1755
|
+
'rounds that revision actually addressed and the NEW revision number — only rounds you genuinely acted ' +
|
|
1756
|
+
"on. The user can flip a round back to 'reopened' when the change wasn't material; treat a reopened " +
|
|
1757
|
+
'round as open feedback on the next pass (resolve_feedback re-resolves it). Keep polling — do NOT call ' +
|
|
1758
|
+
'present_mocks ' +
|
|
1759
|
+
'again for iterations of the same design. Requires an open session (begin_work). If mocks are created ' +
|
|
1760
|
+
'but the review request fails, the error includes the created artifacts and exact recovery instructions ' +
|
|
1761
|
+
'— follow them instead of calling this tool again.',
|
|
1762
|
+
// This schema DECLARES every field the handler reads and CONSTRAINS none
|
|
1763
|
+
// of what the handler validates — the two halves of the Phase 1 defect.
|
|
1764
|
+
// DECLARE, because the SDK parses args with z.object(inputSchema) and
|
|
1765
|
+
// passes parseResult.data on, so a Zod object silently STRIPS anything it
|
|
1766
|
+
// was not told about: a field left off is discarded and never seen by the
|
|
1767
|
+
// handler (that is how `options` vanished in Phase 1). Do NOT constrain,
|
|
1768
|
+
// because a zod refusal is what the AGENT receives: a `.min(1)` here, or
|
|
1769
|
+
// `.min(2).max(11)` on options, pre-empts the handler's written message
|
|
1770
|
+
// ("use html for a single mock; a review decision is capped at 12 options
|
|
1771
|
+
// including the reserved one") with a bare "Too small: expected array to
|
|
1772
|
+
// have >=2 items" — true, but it doesn't say what to do instead. So the
|
|
1773
|
+
// real bounds live in `.describe()`, which is how tools/list teaches the
|
|
1774
|
+
// agent the shape up front, and are ENFORCED by the shared handler, which
|
|
1775
|
+
// rejects every case the removed constraints did. present_wireframes
|
|
1776
|
+
// deliberately still carries those constraints: it is feature 09's
|
|
1777
|
+
// shipped surface, and changing the errors its agents already get is not
|
|
1778
|
+
// this feature's call to make.
|
|
1779
|
+
inputSchema: {
|
|
1780
|
+
title: z.string().min(1).describe('Short title for the presentation (shown on the work item)'),
|
|
1781
|
+
html: z
|
|
1782
|
+
.string()
|
|
1783
|
+
.optional()
|
|
1784
|
+
.describe('A single self-contained interactive mock: HTML + CSS + JS inline, no external assets, ' +
|
|
1785
|
+
'no network, no storage (exactly one of html / options)'),
|
|
1786
|
+
options: z
|
|
1787
|
+
.array(z.object({ label: z.string(), html: z.string() }))
|
|
1788
|
+
.optional()
|
|
1789
|
+
.describe('2..11 competing mocks, each self-contained and interactive, with a short distinct label ' +
|
|
1790
|
+
"(exactly one of html / options; the labels 'approve' and 'request changes' are reserved)"),
|
|
1791
|
+
},
|
|
1792
|
+
}, async (args) => {
|
|
1793
|
+
touchSession(connectionId);
|
|
1794
|
+
return presentMocksHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1795
|
+
});
|
|
1796
|
+
server.registerTool('resolve_feedback', {
|
|
1797
|
+
description: 'Mark feedback rounds resolved after you revised the presented artifact: pass the ids of the rounds the ' +
|
|
1798
|
+
'revision actually addressed (from get_task\'s `feedback` list) and the NEW revision number ' +
|
|
1799
|
+
'update_artifact returned. Only mark rounds you genuinely acted on. The user can flip a round back to ' +
|
|
1800
|
+
"'reopened' when the change wasn't material — treat it as open feedback on your next pass and re-resolve " +
|
|
1801
|
+
'it with this tool. Round bodies never change; only resolution state does. Requires an open session (begin_work).',
|
|
1802
|
+
inputSchema: {
|
|
1803
|
+
feedback_ids: z
|
|
1804
|
+
.array(z.string())
|
|
1805
|
+
.min(1)
|
|
1806
|
+
.describe("Ids of the feedback rounds the revision addressed (from get_task's feedback list)"),
|
|
1807
|
+
revision: z
|
|
1808
|
+
.number()
|
|
1809
|
+
.int()
|
|
1810
|
+
.positive()
|
|
1811
|
+
.describe('The artifact revision that addressed these rounds (from the update_artifact result)'),
|
|
1812
|
+
},
|
|
1813
|
+
}, async (args) => {
|
|
1814
|
+
touchSession(connectionId);
|
|
1815
|
+
return resolveFeedbackHandler(client, openSessions.get(connectionId) ?? null, args);
|
|
1816
|
+
});
|
|
1181
1817
|
server.registerTool('record_user_input', {
|
|
1182
1818
|
description: "Record the user's answer to an open decision on your work item (e.g. an answer they gave you directly). " +
|
|
1183
1819
|
'Updates the decision to decided; it then shows answered in the web UI.',
|
|
@@ -1406,6 +2042,7 @@ export async function startToolsServer(deps) {
|
|
|
1406
2042
|
// can reach Supabase without a live MCP connection in hand.
|
|
1407
2043
|
toolsClient = deps.client;
|
|
1408
2044
|
const port = TOOLS_SERVER_PORT;
|
|
2045
|
+
const token = mcpToken();
|
|
1409
2046
|
const sessions = new Map();
|
|
1410
2047
|
async function handleHttp(req, res) {
|
|
1411
2048
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
@@ -1418,6 +2055,22 @@ export async function startToolsServer(deps) {
|
|
|
1418
2055
|
res.writeHead(404).end('Not found');
|
|
1419
2056
|
return;
|
|
1420
2057
|
}
|
|
2058
|
+
// Client auth: only clients registered by CTRL+SPC carry the per-install
|
|
2059
|
+
// token (embedded in the URL written to Claude/Codex configs). Any other
|
|
2060
|
+
// local process that finds the loopback port is refused before the MCP
|
|
2061
|
+
// protocol — and the credential tools — are reachable.
|
|
2062
|
+
if (url.searchParams.get('token') !== token) {
|
|
2063
|
+
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Unauthorized');
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
// Logout gate: terminal `cs logout` runs in a different process and can only
|
|
2067
|
+
// clear session.json — it can't reach this server's in-memory session. So
|
|
2068
|
+
// every request (including ones on an already-open MCP connection) re-checks
|
|
2069
|
+
// the on-disk session; once it's gone, nothing is served for the old account.
|
|
2070
|
+
if (!readSession()) {
|
|
2071
|
+
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('Logged out');
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
1421
2074
|
try {
|
|
1422
2075
|
const sessionIdHeader = req.headers['mcp-session-id'];
|
|
1423
2076
|
const sessionId = typeof sessionIdHeader === 'string' ? sessionIdHeader : undefined;
|
|
@@ -1589,7 +2242,7 @@ export function registerWithClaude(port) {
|
|
|
1589
2242
|
if (regStatus.claude === 'registering')
|
|
1590
2243
|
return;
|
|
1591
2244
|
regStatus.claude = 'registering';
|
|
1592
|
-
const target = `http://127.0.0.1:${port}/mcp`;
|
|
2245
|
+
const target = `http://127.0.0.1:${port}/mcp?token=${mcpToken()}`;
|
|
1593
2246
|
opChain.claude = opChain.claude.then(() => runRegister('claude', bin, ['mcp', 'remove', '--scope', 'user', 'ctrl-spc'], ['mcp', 'add', '--scope', 'user', '--transport', 'http', 'ctrl-spc', target]));
|
|
1594
2247
|
}
|
|
1595
2248
|
/**
|
|
@@ -1608,7 +2261,7 @@ export function registerWithCodex(port) {
|
|
|
1608
2261
|
if (regStatus.codex === 'registering')
|
|
1609
2262
|
return;
|
|
1610
2263
|
regStatus.codex = 'registering';
|
|
1611
|
-
const target = `http://127.0.0.1:${port}/mcp`;
|
|
2264
|
+
const target = `http://127.0.0.1:${port}/mcp?token=${mcpToken()}`;
|
|
1612
2265
|
opChain.codex = opChain.codex.then(() => runRegister('codex', bin, ['mcp', 'remove', 'ctrl-spc'], ['mcp', 'add', 'ctrl-spc', '--url', target]));
|
|
1613
2266
|
}
|
|
1614
2267
|
/** The actual `mcp remove` for logout cleanup, run ON the agent's op chain so it
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
// Resolve relative to this module so the same lookup works from source, built
|
|
3
|
+
// dist, and an installed npm package. npm always includes the package's root
|
|
4
|
+
// package.json, even though the published files allowlist contains only dist.
|
|
5
|
+
const packageMetadata = createRequire(import.meta.url)('../package.json');
|
|
6
|
+
if (typeof packageMetadata.version !== 'string' || packageMetadata.version.length === 0) {
|
|
7
|
+
throw new Error('The installed @ctrl-spc/cs package does not declare a version.');
|
|
8
|
+
}
|
|
9
|
+
/** The version of the package that is actually running this process. */
|
|
10
|
+
export const CLI_VERSION = packageMetadata.version;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CLI_VERSION } from './package-version.js';
|
|
2
|
+
/** Build the complete, path-free cliv2_agents row reported on every heartbeat. */
|
|
3
|
+
export function buildPresenceHeartbeatPayload(input, seenAt = new Date()) {
|
|
4
|
+
return {
|
|
5
|
+
user_id: input.userId,
|
|
6
|
+
machine_id: input.machineId,
|
|
7
|
+
machine_name: input.machineName,
|
|
8
|
+
agents: input.agents,
|
|
9
|
+
platform: input.platform,
|
|
10
|
+
cli_version: CLI_VERSION,
|
|
11
|
+
last_seen_at: seenAt.toISOString(),
|
|
12
|
+
};
|
|
13
|
+
}
|
package/dist/presence.js
CHANGED
|
@@ -4,6 +4,7 @@ import { getMachineIdentity, supersededMachineIds, clearSupersededMachineIds } f
|
|
|
4
4
|
import { detectAgents } from './agents.js';
|
|
5
5
|
import { startToolsServer, stopToolsServer, toolsServerStatus, registerWithClaude, registerWithCodex, unregisterFromClaude, unregisterFromCodex, agentRegStatus, heartbeatOpenSessions, setToolsClient, } from './mcp.js';
|
|
6
6
|
import { HEARTBEAT_INTERVAL_MS, COMMAND_POLL_INTERVAL_MS } from './env.js';
|
|
7
|
+
import { buildPresenceHeartbeatPayload } from './presence-heartbeat.js';
|
|
7
8
|
let presence = null;
|
|
8
9
|
/** In-flight guard: startPresence yields to the event loop (network setSession)
|
|
9
10
|
* before `presence` is assigned, so a plain `if (presence)` check lets two
|
|
@@ -17,14 +18,13 @@ async function heartbeat(p) {
|
|
|
17
18
|
try {
|
|
18
19
|
const { error } = await p.client
|
|
19
20
|
.from('cliv2_agents')
|
|
20
|
-
.upsert({
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
.upsert(buildPresenceHeartbeatPayload({
|
|
22
|
+
userId: p.userId,
|
|
23
|
+
machineId: p.identity.id,
|
|
24
|
+
machineName: p.identity.name,
|
|
24
25
|
agents: p.agents,
|
|
25
26
|
platform: p.platform,
|
|
26
|
-
|
|
27
|
-
}, { onConflict: 'user_id,machine_id' });
|
|
27
|
+
}), { onConflict: 'user_id,machine_id' });
|
|
28
28
|
if (error)
|
|
29
29
|
throw error;
|
|
30
30
|
}
|
package/dist/supabase.js
CHANGED
|
@@ -68,7 +68,10 @@ export async function getClient() {
|
|
|
68
68
|
global: { fetch: retryingFetch },
|
|
69
69
|
});
|
|
70
70
|
client.auth.onAuthStateChange((_event, session) => {
|
|
71
|
-
|
|
71
|
+
// Update-only: a token refresh must never recreate session.json after
|
|
72
|
+
// `cs logout` deleted it, or the logged-out machine would re-authorize
|
|
73
|
+
// itself up to an hour later.
|
|
74
|
+
if (session && readSession()) {
|
|
72
75
|
writeSession({ access_token: session.access_token, refresh_token: session.refresh_token });
|
|
73
76
|
}
|
|
74
77
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ctrl-spc/cs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22"
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "tsc",
|
|
20
|
-
"start": "npm run build && node dist/index.js"
|
|
20
|
+
"start": "npm run build && node dist/index.js",
|
|
21
|
+
"test": "npm run build && node --test test/*.test.mjs"
|
|
21
22
|
},
|
|
22
23
|
"license": "UNLICENSED",
|
|
23
24
|
"dependencies": {
|
|
@@ -25,6 +26,9 @@
|
|
|
25
26
|
"@supabase/supabase-js": "^2.110.1",
|
|
26
27
|
"zod": "^4.4.3"
|
|
27
28
|
},
|
|
29
|
+
"overrides": {
|
|
30
|
+
"@hono/node-server": "^2.0.5"
|
|
31
|
+
},
|
|
28
32
|
"devDependencies": {
|
|
29
33
|
"@types/node": "^26.1.1",
|
|
30
34
|
"typescript": "^5.9.3"
|