@ctrl-spc/cs 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/agents.js +6 -0
- package/dist/companion-ui.js +205 -3
- package/dist/companion.js +23 -1
- package/dist/config.js +14 -1
- package/dist/env.js +10 -0
- package/dist/mcp.js +2332 -0
- package/dist/package-version.js +10 -0
- package/dist/presence-heartbeat.js +13 -0
- package/dist/presence.js +70 -8
- package/dist/supabase.js +4 -1
- package/package.json +9 -3
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,2332 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
7
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
|
|
10
|
+
import { agentPath } from './agents.js';
|
|
11
|
+
import { mcpToken, readSession } from './config.js';
|
|
12
|
+
export function attributionFromClientName(name) {
|
|
13
|
+
if (!name)
|
|
14
|
+
return null;
|
|
15
|
+
if (/claude/i.test(name))
|
|
16
|
+
return 'claude';
|
|
17
|
+
if (/codex/i.test(name))
|
|
18
|
+
return 'codex';
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/** Awaits a Supabase call and throws on error — callers catch once, at the
|
|
22
|
+
* handler boundary (mirrors v1's `must`). */
|
|
23
|
+
async function must(query) {
|
|
24
|
+
const { data, error } = await query;
|
|
25
|
+
if (error)
|
|
26
|
+
throw new Error(error.message);
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
function textResult(payload) {
|
|
30
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
31
|
+
}
|
|
32
|
+
function errorResult(message) {
|
|
33
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
34
|
+
}
|
|
35
|
+
/** Columns v1 returns for a freshly created artifact (cli/src/mcp.ts:2239) —
|
|
36
|
+
* reused verbatim so get_task and create_artifact hand back the same shape, plus
|
|
37
|
+
* `revision` so the agent has the optimistic-concurrency token update_artifact
|
|
38
|
+
* needs for `expected_revision` (mirrors how get_task returns tasks.revision). */
|
|
39
|
+
const ARTIFACT_COLUMNS = 'id,task_id,type,format,title,purpose_key,system_kind,content,storage_path,created_by,from_agent,created_at,revision';
|
|
40
|
+
/** Columns a decision row exposes to the agent (D3) — reused verbatim so get_task
|
|
41
|
+
* and ask_question hand back the same shape. RLS `decisions_select` grants the
|
|
42
|
+
* signed-in user read via `app.can_access_task`. */
|
|
43
|
+
const DECISION_COLUMNS = 'id,task_id,state,category,context,question,answer_mode,options,answer,selected_options,answer_note,question_comment_id,related_artifact_id,asked_by,asked_by_agent_run_id,asked_at,decided_by,decided_at';
|
|
44
|
+
/** Tags come from the same `task_tags`→`tags` join the web reads. */
|
|
45
|
+
const TASK_TAGS = 'task_tags:task_tags!task_tags_task_id_fkey(tag:tags!task_tags_tag_id_fkey(id,name))';
|
|
46
|
+
function tagsOf(row) {
|
|
47
|
+
return (row.task_tags ?? [])
|
|
48
|
+
.map((entry) => entry.tag?.name)
|
|
49
|
+
.filter((name) => Boolean(name))
|
|
50
|
+
.sort((a, b) => a.localeCompare(b));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* List the signed-in user's tasks across all their projects (RLS-scoped),
|
|
54
|
+
* optionally filtered to one project. No machine/workspace resolver — RLS on
|
|
55
|
+
* `tasks` already confines the rows to the user's own projects.
|
|
56
|
+
*/
|
|
57
|
+
async function listTasksHandler(client, args) {
|
|
58
|
+
try {
|
|
59
|
+
const projects = (await must(client.from('projects').select('id, name'))) ?? [];
|
|
60
|
+
let query = client
|
|
61
|
+
.from('tasks')
|
|
62
|
+
.select(`id, project_id, name, status, due_date, ${TASK_TAGS}`)
|
|
63
|
+
.is('archived_at', null);
|
|
64
|
+
if (args.project_id)
|
|
65
|
+
query = query.eq('project_id', args.project_id);
|
|
66
|
+
const rows = (await must(query
|
|
67
|
+
.order('status', { ascending: true })
|
|
68
|
+
.order('position', { ascending: true })
|
|
69
|
+
.order('created_at', { ascending: true })
|
|
70
|
+
.order('id', { ascending: true }))) ?? [];
|
|
71
|
+
const tasks = rows.map((row) => ({
|
|
72
|
+
id: row.id,
|
|
73
|
+
project_id: row.project_id,
|
|
74
|
+
project: projects.find((p) => p.id === row.project_id) ?? { id: row.project_id },
|
|
75
|
+
name: row.name,
|
|
76
|
+
status: row.status,
|
|
77
|
+
due_date: row.due_date,
|
|
78
|
+
tags: tagsOf(row),
|
|
79
|
+
}));
|
|
80
|
+
return textResult({ projects, tasks });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return errorResult(`list_tasks failed: ${err.message}`);
|
|
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
|
+
}
|
|
106
|
+
/**
|
|
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).
|
|
143
|
+
* Adapted from v1's `getTaskHandler` for the column shapes, with every
|
|
144
|
+
* topology / checkout / coordination section dropped.
|
|
145
|
+
*/
|
|
146
|
+
async function getTaskHandler(client, args) {
|
|
147
|
+
try {
|
|
148
|
+
const row = await must(client
|
|
149
|
+
.from('tasks')
|
|
150
|
+
.select(`*, ${TASK_TAGS}`)
|
|
151
|
+
.eq('id', args.id)
|
|
152
|
+
.is('archived_at', null)
|
|
153
|
+
.maybeSingle());
|
|
154
|
+
if (!row)
|
|
155
|
+
return errorResult(`No task found for id "${args.id}".`);
|
|
156
|
+
const [comments, artifacts, decisions, feedback] = await Promise.all([
|
|
157
|
+
must(client
|
|
158
|
+
.from('comments')
|
|
159
|
+
.select('id,task_id,body,author_id,from_agent,agent_run_id,created_at,updated_at')
|
|
160
|
+
.eq('task_id', row.id)
|
|
161
|
+
.order('created_at', { ascending: true })
|
|
162
|
+
.order('id', { ascending: true })),
|
|
163
|
+
must(client
|
|
164
|
+
.from('artifacts')
|
|
165
|
+
.select(ARTIFACT_COLUMNS)
|
|
166
|
+
.eq('task_id', row.id)
|
|
167
|
+
.is('deleted_at', null)
|
|
168
|
+
.order('created_at', { ascending: true })
|
|
169
|
+
.order('id', { ascending: true })),
|
|
170
|
+
// D3: the item's decisions — the questions ask_question opened and the
|
|
171
|
+
// answers the user gave in the web UI, so the agent can read them back.
|
|
172
|
+
must(client
|
|
173
|
+
.from('decisions')
|
|
174
|
+
.select(DECISION_COLUMNS)
|
|
175
|
+
.eq('task_id', row.id)
|
|
176
|
+
.order('asked_at', { ascending: true })
|
|
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),
|
|
189
|
+
]);
|
|
190
|
+
const { task_tags: _drop, ...task } = row;
|
|
191
|
+
return textResult({
|
|
192
|
+
task: { ...task, tags: tagsOf(row) },
|
|
193
|
+
comments: comments ?? [],
|
|
194
|
+
artifacts: artifacts ?? [],
|
|
195
|
+
decisions: decisions ?? [],
|
|
196
|
+
feedback: feedback ?? [],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
return errorResult(`get_task failed: ${err.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Create an artifact on a task the user owns (via RLS). Adapted from v1's simple
|
|
205
|
+
* (non-agent-run) path (cli/src/mcp.ts:2219): the task is looked up by id under
|
|
206
|
+
* RLS (no bound project), and the artifact is created AS THE USER
|
|
207
|
+
* (`created_by: userId`). `from_agent` is left null because the deployed DB guard
|
|
208
|
+
* `artifact_agent_insert_guard` reserves agent attribution for the coordinated
|
|
209
|
+
* `create_agent_artifact` RPC — the agent-run model this availability-only
|
|
210
|
+
* feature deliberately does not use. A direct `authenticated` insert that sets
|
|
211
|
+
* `from_agent` is rejected ("agent artifacts must be created through
|
|
212
|
+
* create_agent_artifact"), so un-coordinated tool use writes as the user.
|
|
213
|
+
*/
|
|
214
|
+
async function createArtifactHandler(client, userId, currentSession, args) {
|
|
215
|
+
try {
|
|
216
|
+
if (!args.task_id)
|
|
217
|
+
return errorResult('create_artifact requires task_id.');
|
|
218
|
+
if (!args.content)
|
|
219
|
+
return errorResult('create_artifact requires content.');
|
|
220
|
+
if (args.purpose_key?.trim().startsWith('context:')) {
|
|
221
|
+
return errorResult('Context is a reserved work-item artifact.');
|
|
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
|
+
}
|
|
234
|
+
const task = await must(client.from('tasks').select('id').eq('id', args.task_id).is('archived_at', null).maybeSingle());
|
|
235
|
+
if (!task)
|
|
236
|
+
return errorResult(`No task found for id "${args.task_id}".`);
|
|
237
|
+
const row = await must(client
|
|
238
|
+
.from('artifacts')
|
|
239
|
+
.insert({
|
|
240
|
+
task_id: args.task_id,
|
|
241
|
+
type: args.type,
|
|
242
|
+
format: args.format ?? 'md',
|
|
243
|
+
...(args.title?.trim() ? { title: args.title.trim() } : {}),
|
|
244
|
+
...(args.purpose_key?.trim() ? { purpose_key: args.purpose_key.trim() } : {}),
|
|
245
|
+
content: args.content,
|
|
246
|
+
created_by: userId,
|
|
247
|
+
from_agent: null,
|
|
248
|
+
agent_run_id: null,
|
|
249
|
+
})
|
|
250
|
+
.select(ARTIFACT_COLUMNS)
|
|
251
|
+
.single());
|
|
252
|
+
if (!row)
|
|
253
|
+
throw new Error('Artifact insert returned no row.');
|
|
254
|
+
// D1 attribution: if this connection has an open session for THIS task
|
|
255
|
+
// (the agent called begin_work first), record that the session produced this
|
|
256
|
+
// artifact — one cliv2_agent_outputs row, referencing the artifact BY VALUE.
|
|
257
|
+
// Best-effort: the artifact already exists and is the real return value, so a
|
|
258
|
+
// failed attribution write must NOT fail the tool — but it is surfaced in the
|
|
259
|
+
// returned text rather than silently dropped. With no open session (or a
|
|
260
|
+
// session for a different task) no attribution row is written — backward
|
|
261
|
+
// compatible with un-coordinated tool use.
|
|
262
|
+
let attributionWarning;
|
|
263
|
+
if (currentSession && currentSession.taskId === args.task_id) {
|
|
264
|
+
try {
|
|
265
|
+
await must(client.from('cliv2_agent_outputs').insert({
|
|
266
|
+
user_id: userId,
|
|
267
|
+
session_id: currentSession.sessionId,
|
|
268
|
+
kind: 'artifact',
|
|
269
|
+
product_id: row.id,
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
attributionWarning = `Artifact created, but recording session attribution failed: ${err.message}`;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return textResult(attributionWarning ? { artifact: row, attribution_warning: attributionWarning } : { artifact: row });
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
return errorResult(`create_artifact failed: ${err.message}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** Re-fetch one task with the SAME select get_task uses (`*` + the tag join),
|
|
283
|
+
* returning the parsed task object ({ ...row, tags }, task_tags dropped) or null.
|
|
284
|
+
* Shared by update_task and create_task so their result shape matches get_task's
|
|
285
|
+
* `task`. */
|
|
286
|
+
async function fetchTask(client, id) {
|
|
287
|
+
const row = await must(client.from('tasks').select(`*, ${TASK_TAGS}`).eq('id', id).is('archived_at', null).maybeSingle());
|
|
288
|
+
if (!row)
|
|
289
|
+
return null;
|
|
290
|
+
const { task_tags: _drop, ...task } = row;
|
|
291
|
+
return { ...task, tags: tagsOf(row) };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Update a task the user owns (via RLS). Adapted from v1's SIMPLE (non-agent-run)
|
|
295
|
+
* `updateTaskHandler` branch: no bound project (RLS scopes it), status + the plain
|
|
296
|
+
* text fields only — owner / sprint / tags / reorder / before_task_id are all
|
|
297
|
+
* deferred. Optimistic concurrency via `expected_revision` (from get_task) through
|
|
298
|
+
* the same `save_task_if_current` RPC v1 uses; an `edit_conflict` maps to a clear
|
|
299
|
+
* "call get_task again and retry" message. Re-fetches with get_task's select.
|
|
300
|
+
*/
|
|
301
|
+
async function updateTaskHandler(client, args) {
|
|
302
|
+
try {
|
|
303
|
+
const { id, fields, expected_revision } = args;
|
|
304
|
+
if (!Number.isInteger(expected_revision) || expected_revision < 1) {
|
|
305
|
+
return errorResult('update_task requires expected_revision (a positive integer) from the most recent get_task response.');
|
|
306
|
+
}
|
|
307
|
+
const current = await must(client.from('tasks').select('id,status,revision').eq('id', id).is('archived_at', null).maybeSingle());
|
|
308
|
+
if (!current)
|
|
309
|
+
return errorResult(`No task found for id "${id}".`);
|
|
310
|
+
const patch = {};
|
|
311
|
+
if (fields.name !== undefined)
|
|
312
|
+
patch.name = fields.name;
|
|
313
|
+
if (fields.description !== undefined)
|
|
314
|
+
patch.description = fields.description;
|
|
315
|
+
if (fields.due_date !== undefined)
|
|
316
|
+
patch.due_date = fields.due_date;
|
|
317
|
+
if (fields.status !== undefined)
|
|
318
|
+
patch.status = fields.status;
|
|
319
|
+
// A status change implies append-to-destination (reorder); a same-value
|
|
320
|
+
// status preserves board position. before_task_id ordering is deferred, so
|
|
321
|
+
// reorder is driven purely by whether the status actually changed.
|
|
322
|
+
const statusChanged = fields.status !== undefined && fields.status !== current.status;
|
|
323
|
+
await must(client.rpc('save_task_if_current', {
|
|
324
|
+
p_task_id: id,
|
|
325
|
+
p_expected_revision: expected_revision,
|
|
326
|
+
p_changes: patch,
|
|
327
|
+
p_tag_ids: null,
|
|
328
|
+
p_reorder: statusChanged,
|
|
329
|
+
p_before_task_id: null,
|
|
330
|
+
}));
|
|
331
|
+
const task = await fetchTask(client, id);
|
|
332
|
+
if (!task)
|
|
333
|
+
throw new Error('Updated task could not be re-fetched.');
|
|
334
|
+
return textResult({ task });
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
if (err.message === 'edit_conflict') {
|
|
338
|
+
return errorResult('update_task conflict: this work item changed after it was read. Call get_task again, review the latest revision, and retry intentionally.');
|
|
339
|
+
}
|
|
340
|
+
return errorResult(`update_task failed: ${err.message}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Edit an artifact the user can access (via RLS). Mirrors updateTaskHandler's
|
|
345
|
+
* optimistic-concurrency shape exactly, over the run-free `save_artifact_if_current`
|
|
346
|
+
* RPC (security invoker, granted authenticated, NO p_run — the coordinated
|
|
347
|
+
* create_agent_artifact path is deliberately unused here). The deployed RPC accepts
|
|
348
|
+
* the artifact BODY keys `title` / `purpose_key` / `type` / `format` / `content`
|
|
349
|
+
* (plus `deleted_at`, which this tool never sends); this tool exposes
|
|
350
|
+
* `title` / `type` / `format` / `content` and deliberately NOT `purpose_key` — an
|
|
351
|
+
* UPDATE that set `purpose_key='context:<task>'` could relabel a normal artifact as
|
|
352
|
+
* canonical context and thereby escape the INSERT-only context-artifact reservation.
|
|
353
|
+
* `from_agent` is never touched. `expected_revision` comes from the most recent
|
|
354
|
+
* get_task; a revision mismatch raises `edit_conflict`, mapped to the same clear
|
|
355
|
+
* "call get_task again and retry" message updateTask uses. Re-fetches the live
|
|
356
|
+
* artifact with ARTIFACT_COLUMNS so the result matches get_task / create_artifact.
|
|
357
|
+
*/
|
|
358
|
+
async function updateArtifactHandler(client, args) {
|
|
359
|
+
try {
|
|
360
|
+
const { id, fields, expected_revision } = args;
|
|
361
|
+
if (!Number.isInteger(expected_revision) || expected_revision < 1) {
|
|
362
|
+
return errorResult('update_artifact requires expected_revision (a positive integer) from the most recent get_task response.');
|
|
363
|
+
}
|
|
364
|
+
// Build p_changes from the provided fields only — restricted to the keys this
|
|
365
|
+
// tool exposes (title / type / format / content). purpose_key is deliberately
|
|
366
|
+
// excluded (it could escape the context-artifact reservation), and deleted_at is
|
|
367
|
+
// never sent from here.
|
|
368
|
+
const patch = {};
|
|
369
|
+
if (fields.title !== undefined)
|
|
370
|
+
patch.title = fields.title;
|
|
371
|
+
if (fields.type !== undefined)
|
|
372
|
+
patch.type = fields.type;
|
|
373
|
+
if (fields.format !== undefined)
|
|
374
|
+
patch.format = fields.format;
|
|
375
|
+
if (fields.content !== undefined)
|
|
376
|
+
patch.content = fields.content;
|
|
377
|
+
await must(client.rpc('save_artifact_if_current', {
|
|
378
|
+
p_artifact_id: id,
|
|
379
|
+
p_expected_revision: expected_revision,
|
|
380
|
+
p_changes: patch,
|
|
381
|
+
}));
|
|
382
|
+
const artifact = await must(client.from('artifacts').select(ARTIFACT_COLUMNS).eq('id', id).is('deleted_at', null).maybeSingle());
|
|
383
|
+
if (!artifact)
|
|
384
|
+
throw new Error('Updated artifact could not be re-fetched.');
|
|
385
|
+
return textResult({ artifact });
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
if (err.message === 'edit_conflict') {
|
|
389
|
+
return errorResult('update_artifact conflict: this artifact changed after it was read. Call get_task again, re-read the latest revision, and retry intentionally.');
|
|
390
|
+
}
|
|
391
|
+
return errorResult(`update_artifact failed: ${err.message}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Set the repository role slugs on a task — its role assignments. Replaces
|
|
396
|
+
* tasks.role_slugs with the given list via the run-free `set_task_role_slugs` RPC
|
|
397
|
+
* (security definer, granted authenticated, NO p_run; checks app.is_project_member).
|
|
398
|
+
* The RPC filters the requested slugs down to the project's active repository scopes,
|
|
399
|
+
* so a caller can't assign a slug the project doesn't define. Re-fetches with
|
|
400
|
+
* get_task's select so the result shape matches get_task's `task`.
|
|
401
|
+
*
|
|
402
|
+
* The RPC has no archived guard, but fetchTask filters `archived_at is null`, so
|
|
403
|
+
* without a pre-check a task archived after it was read would be written at the DB
|
|
404
|
+
* yet reported as a re-fetch failure. Mirror updateTaskHandler: confirm the task is
|
|
405
|
+
* live under RLS BEFORE calling the RPC, and return a clean "no task" error if not.
|
|
406
|
+
*/
|
|
407
|
+
async function setTaskRoleSlugsHandler(client, args) {
|
|
408
|
+
try {
|
|
409
|
+
const task = await must(client.from('tasks').select('id').eq('id', args.task_id).is('archived_at', null).maybeSingle());
|
|
410
|
+
if (!task)
|
|
411
|
+
return errorResult(`No task found for id "${args.task_id}".`);
|
|
412
|
+
await must(client.rpc('set_task_role_slugs', { p_task: args.task_id, p_role_slugs: args.role_slugs }));
|
|
413
|
+
const updated = await fetchTask(client, args.task_id);
|
|
414
|
+
if (!updated)
|
|
415
|
+
throw new Error('Task could not be re-fetched.');
|
|
416
|
+
return textResult({ task: updated });
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
return errorResult(`set_task_role_slugs failed: ${err.message}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Leave a note on a task the user owns (via RLS). Adapted from v1's
|
|
424
|
+
* `addCommentHandler`, authored AS THE USER: `from_agent` is left null. Like
|
|
425
|
+
* `artifacts`, the `comments` table carries a `comment_agent_insert_guard` that
|
|
426
|
+
* reserves agent attribution for the coordinated path this feature does not use,
|
|
427
|
+
* so a direct `from_agent`-set insert would be rejected — writing as the user is
|
|
428
|
+
* the correct, consistent choice (matches create_artifact).
|
|
429
|
+
*/
|
|
430
|
+
async function addCommentHandler(client, userId, currentSession, args) {
|
|
431
|
+
try {
|
|
432
|
+
if (!args.task_id)
|
|
433
|
+
return errorResult('add_comment requires task_id.');
|
|
434
|
+
if (!args.body || !args.body.trim())
|
|
435
|
+
return errorResult('add_comment requires a non-empty body.');
|
|
436
|
+
const task = await must(client.from('tasks').select('id').eq('id', args.task_id).is('archived_at', null).maybeSingle());
|
|
437
|
+
if (!task)
|
|
438
|
+
return errorResult(`No task found for id "${args.task_id}".`);
|
|
439
|
+
const row = await must(client
|
|
440
|
+
.from('comments')
|
|
441
|
+
.insert({
|
|
442
|
+
task_id: args.task_id,
|
|
443
|
+
author_id: userId,
|
|
444
|
+
body: args.body,
|
|
445
|
+
from_agent: null,
|
|
446
|
+
agent_run_id: null,
|
|
447
|
+
})
|
|
448
|
+
.select('id,task_id,body,author_id,from_agent,created_at')
|
|
449
|
+
.single());
|
|
450
|
+
if (!row)
|
|
451
|
+
throw new Error('Comment insert returned no row.');
|
|
452
|
+
// D2a attribution: if this connection has an open session (the agent called
|
|
453
|
+
// begin_work), record that the session produced this comment — one
|
|
454
|
+
// cliv2_agent_outputs row referencing the comment BY VALUE. Unlike
|
|
455
|
+
// create_artifact this does NOT require the session's task to match: a comment
|
|
456
|
+
// produced while a session is open is attributed to it. Best-effort (mirrors
|
|
457
|
+
// D1): the comment already exists and is the real return value, so a failed
|
|
458
|
+
// attribution write must NOT fail the tool — it is surfaced in the returned
|
|
459
|
+
// text rather than silently dropped. With no open session no attribution row is
|
|
460
|
+
// written — backward compatible with un-coordinated tool use.
|
|
461
|
+
let attributionWarning;
|
|
462
|
+
if (currentSession) {
|
|
463
|
+
try {
|
|
464
|
+
await must(client.from('cliv2_agent_outputs').insert({
|
|
465
|
+
user_id: userId,
|
|
466
|
+
session_id: currentSession.sessionId,
|
|
467
|
+
kind: 'comment',
|
|
468
|
+
product_id: row.id,
|
|
469
|
+
}));
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
attributionWarning = `Comment created, but recording session attribution failed: ${err.message}`;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return textResult(attributionWarning ? { comment: row, attribution_warning: attributionWarning } : { comment: row });
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
return errorResult(`add_comment failed: ${err.message}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Create a new task in a project the user owns (via RLS), owned by the user.
|
|
483
|
+
* Adapted from v1's `createTaskHandler`, minimal — tags / depends_on /
|
|
484
|
+
* before_task_id are deferred. Uses the workflow-free `create_task` RPC
|
|
485
|
+
* (SECURITY DEFINER; the old `create_task_with_workflow` was removed with the
|
|
486
|
+
* Workflows feature, see 20260721150000_drop_workflows.sql) to insert the task
|
|
487
|
+
* and return its id, then re-fetches with get_task's select.
|
|
488
|
+
*/
|
|
489
|
+
async function createTaskHandler(client, userId, currentSession, args) {
|
|
490
|
+
try {
|
|
491
|
+
if (!args.name || !args.name.trim())
|
|
492
|
+
return errorResult('create_task requires a non-empty name.');
|
|
493
|
+
const taskId = await must(client.rpc('create_task', {
|
|
494
|
+
p_project: args.project_id,
|
|
495
|
+
p_name: args.name,
|
|
496
|
+
p_description: args.description ?? '',
|
|
497
|
+
p_owner: userId,
|
|
498
|
+
}));
|
|
499
|
+
if (!taskId)
|
|
500
|
+
throw new Error('Task insert returned no id.');
|
|
501
|
+
const task = await fetchTask(client, taskId);
|
|
502
|
+
if (!task)
|
|
503
|
+
throw new Error('Created task could not be re-fetched.');
|
|
504
|
+
// D2a attribution: if this connection has an open session, record that the
|
|
505
|
+
// session produced this NEW task — one cliv2_agent_outputs row referencing the
|
|
506
|
+
// task BY VALUE. This is a follow-up task the subagent created, NOT the
|
|
507
|
+
// session's anchored task, so there is deliberately NO task-id match to gate
|
|
508
|
+
// on: gate only on there being an open session. Best-effort (mirrors D1): the
|
|
509
|
+
// task already exists and is the real return value, so a failed attribution
|
|
510
|
+
// write must NOT fail the tool — it is surfaced in the returned text rather
|
|
511
|
+
// than silently dropped. With no open session no attribution row is written —
|
|
512
|
+
// backward compatible with un-coordinated tool use.
|
|
513
|
+
let attributionWarning;
|
|
514
|
+
if (currentSession) {
|
|
515
|
+
try {
|
|
516
|
+
await must(client.from('cliv2_agent_outputs').insert({
|
|
517
|
+
user_id: userId,
|
|
518
|
+
session_id: currentSession.sessionId,
|
|
519
|
+
kind: 'task',
|
|
520
|
+
product_id: taskId,
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
attributionWarning = `Task created, but recording session attribution failed: ${err.message}`;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return textResult(attributionWarning ? { task, attribution_warning: attributionWarning } : { task });
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
return errorResult(`create_task failed: ${err.message}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Ask the user a question about the work item the open session is on (D3). Unlike
|
|
535
|
+
* the write tools, this REQUIRES an open session — the question is a decision on
|
|
536
|
+
* the session's task, and the `cliv2_ask_question` RPC resolves that task FROM the
|
|
537
|
+
* session (so there's no task_id arg). The RPC inserts the run-free `decisions`
|
|
538
|
+
* row AS THE USER and returns its id; the decision is the real return value, so
|
|
539
|
+
* (mirroring create_artifact) attribution is a best-effort `cliv2_agent_outputs`
|
|
540
|
+
* write whose failure is surfaced in the returned text, never fatal. The created
|
|
541
|
+
* decision is re-fetched with the SAME columns get_task returns so the agent sees
|
|
542
|
+
* exactly what it made and can later read the user's answer back with get_task.
|
|
543
|
+
*/
|
|
544
|
+
async function askQuestionHandler(client, userId, session, args) {
|
|
545
|
+
try {
|
|
546
|
+
if (!session) {
|
|
547
|
+
return errorResult('ask_question needs an open work session — call begin_work first.');
|
|
548
|
+
}
|
|
549
|
+
if (!args.category || !args.category.trim())
|
|
550
|
+
return errorResult('ask_question requires a non-empty category.');
|
|
551
|
+
if (!args.context || !args.context.trim())
|
|
552
|
+
return errorResult('ask_question requires a non-empty context.');
|
|
553
|
+
if (!args.question || !args.question.trim())
|
|
554
|
+
return errorResult('ask_question requires a non-empty question.');
|
|
555
|
+
const mode = args.answer_mode ?? 'free_text';
|
|
556
|
+
const decisionId = await must(client.rpc('cliv2_ask_question', {
|
|
557
|
+
p_session: session.sessionId,
|
|
558
|
+
p_category: args.category,
|
|
559
|
+
p_context: args.context,
|
|
560
|
+
p_question: args.question,
|
|
561
|
+
p_answer_mode: mode,
|
|
562
|
+
p_options: args.options ?? [],
|
|
563
|
+
p_related_artifact: args.related_artifact_id ?? null,
|
|
564
|
+
}));
|
|
565
|
+
if (!decisionId)
|
|
566
|
+
throw new Error('Question insert returned no decision id.');
|
|
567
|
+
// Best-effort attribution (mirrors create_artifact exactly): record that this
|
|
568
|
+
// session asked the question — one cliv2_agent_outputs row referencing the
|
|
569
|
+
// decision BY VALUE. The decision already exists and is the real return value,
|
|
570
|
+
// so a failed attribution write must NOT fail the tool — it is surfaced in the
|
|
571
|
+
// returned text rather than silently dropped.
|
|
572
|
+
let attributionWarning;
|
|
573
|
+
try {
|
|
574
|
+
await must(client.from('cliv2_agent_outputs').insert({
|
|
575
|
+
user_id: userId,
|
|
576
|
+
session_id: session.sessionId,
|
|
577
|
+
kind: 'decision',
|
|
578
|
+
product_id: decisionId,
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
attributionWarning = `Question created, but recording session attribution failed: ${err.message}`;
|
|
583
|
+
}
|
|
584
|
+
// Re-fetch the created decision with the SAME columns get_task returns, so the
|
|
585
|
+
// agent sees what it made (and later reads the answer back through get_task).
|
|
586
|
+
const decision = await must(client.from('decisions').select(DECISION_COLUMNS).eq('id', decisionId).maybeSingle());
|
|
587
|
+
if (!decision)
|
|
588
|
+
throw new Error('Created decision could not be re-fetched.');
|
|
589
|
+
return textResult(attributionWarning ? { decision, attribution_warning: attributionWarning } : { decision });
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
return errorResult(`ask_question failed: ${err.message}`);
|
|
593
|
+
}
|
|
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
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Record the user's answer to an OPEN decision on the work item (feature 05 / D4 —
|
|
1020
|
+
* the run-free twin of v1's `record_user_input`). Unlike v1, this does NOT go through a
|
|
1021
|
+
* run-gated batch RPC (`record_task_decisions` / `record_task_decision_responses`);
|
|
1022
|
+
* it uses the exact RPC the web's `answerDecision` (web/src/lib/actions.ts) calls,
|
|
1023
|
+
* `answer_task_decision_v2({ p_decision_id, p_selected_options, p_answer_note })`,
|
|
1024
|
+
* AS THE USER under RLS — no run, no fencing token. The RPC is security-definer and
|
|
1025
|
+
* validates access (`app.can_access_task`) plus the answer shape against the
|
|
1026
|
+
* decision's `answer_mode`, so it is the source of truth for deep validation.
|
|
1027
|
+
*
|
|
1028
|
+
* The web routes EVERY answer_mode through this single v2 RPC; we replicate that
|
|
1029
|
+
* mapping exactly:
|
|
1030
|
+
* - free_text -> p_selected_options: [], p_answer_note: <answer>
|
|
1031
|
+
* - single/multi_select -> p_selected_options: <options>, p_answer_note: <note ?? ''>
|
|
1032
|
+
* (`answer_task_decision(id, answer)` is only a thin wrapper for v2 with '{}' — the
|
|
1033
|
+
* web never calls it, so neither do we.) We read the decision's `answer_mode` first
|
|
1034
|
+
* only to route + give a clean shape error; RLS scopes that read to the user. This
|
|
1035
|
+
* MUTATES an existing decision, so there is no attribution row (nothing new is
|
|
1036
|
+
* produced). Requires an open session, like ask_question. Re-fetches the decision
|
|
1037
|
+
* with DECISION_COLUMNS so the caller sees the now-decided row (it shows answered in
|
|
1038
|
+
* the web UI).
|
|
1039
|
+
*/
|
|
1040
|
+
async function recordUserInputHandler(client, session, args) {
|
|
1041
|
+
try {
|
|
1042
|
+
if (!session) {
|
|
1043
|
+
return errorResult('record_user_input needs an open work session — call begin_work first.');
|
|
1044
|
+
}
|
|
1045
|
+
if (!args.decision_id || !args.decision_id.trim()) {
|
|
1046
|
+
return errorResult('record_user_input requires a decision_id.');
|
|
1047
|
+
}
|
|
1048
|
+
// Read the decision to route by its answer_mode EXACTLY as the web does, and to
|
|
1049
|
+
// give a clean shape error before the RPC. RLS (`decisions_select`) scopes this
|
|
1050
|
+
// to a decision the signed-in user can access.
|
|
1051
|
+
const decision = await must(client.from('decisions').select('id,answer_mode').eq('id', args.decision_id).maybeSingle());
|
|
1052
|
+
if (!decision)
|
|
1053
|
+
return errorResult(`No decision found for id "${args.decision_id}".`);
|
|
1054
|
+
// Replicate the web's answerDecision mapping into answer_task_decision_v2.
|
|
1055
|
+
let pSelectedOptions;
|
|
1056
|
+
let pAnswerNote;
|
|
1057
|
+
if (decision.answer_mode === 'free_text') {
|
|
1058
|
+
if (!args.answer || !args.answer.trim()) {
|
|
1059
|
+
return errorResult('record_user_input requires answer (the free-text answer) for a free_text decision.');
|
|
1060
|
+
}
|
|
1061
|
+
pSelectedOptions = [];
|
|
1062
|
+
pAnswerNote = args.answer;
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
if (!Array.isArray(args.selected_options) || args.selected_options.length === 0) {
|
|
1066
|
+
return errorResult('record_user_input requires selected_options (the chosen option(s)) for a choice decision.');
|
|
1067
|
+
}
|
|
1068
|
+
pSelectedOptions = args.selected_options;
|
|
1069
|
+
pAnswerNote = args.answer_note ?? '';
|
|
1070
|
+
}
|
|
1071
|
+
await must(client.rpc('answer_task_decision_v2', {
|
|
1072
|
+
p_decision_id: args.decision_id,
|
|
1073
|
+
p_selected_options: pSelectedOptions,
|
|
1074
|
+
p_answer_note: pAnswerNote,
|
|
1075
|
+
}));
|
|
1076
|
+
// Hand back the now-decided decision in the SAME shape get_task / ask_question use.
|
|
1077
|
+
const updated = await must(client.from('decisions').select(DECISION_COLUMNS).eq('id', args.decision_id).maybeSingle());
|
|
1078
|
+
if (!updated)
|
|
1079
|
+
throw new Error('Answered decision could not be re-fetched.');
|
|
1080
|
+
return textResult({ decision: updated });
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
return errorResult(`record_user_input failed: ${err.message}`);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Record what you explored for the work item as its CANONICAL context document
|
|
1088
|
+
* (feature 05 / D4 — the run-free twin of v1's `record_context_exploration`). v1's
|
|
1089
|
+
* version wrote the `system_kind='context'` artifact PLUS repository-coverage /
|
|
1090
|
+
* topology through the run-gated `record_context_exploration_artifact` RPC; we drop
|
|
1091
|
+
* the coverage and topology entirely and write ONLY the context document.
|
|
1092
|
+
*
|
|
1093
|
+
* The canonical context artifact is DB-reserved: the BEFORE-INSERT trigger
|
|
1094
|
+
* `app.guard_agent_artifact_insert` admits a `system_kind='context'` row only when
|
|
1095
|
+
* the session GUC `app.context_artifact_write` matches the task. The run-free
|
|
1096
|
+
* SECURITY DEFINER RPC `cliv2_record_context(p_session, p_content)` sets that GUC and
|
|
1097
|
+
* writes/UPSERTS the ONE canonical "Work item context" artifact for the session's
|
|
1098
|
+
* task — task resolved FROM the session, no run / fencing / topology. So this tool
|
|
1099
|
+
* takes only `content`: the document's title is fixed ("Work item context") and its
|
|
1100
|
+
* task comes from the open session. Requires an open session.
|
|
1101
|
+
*
|
|
1102
|
+
* Because the RPC UPSERTS the same canonical row, repeated calls return the SAME
|
|
1103
|
+
* artifact id. Best-effort attribution mirrors create_artifact (one
|
|
1104
|
+
* cliv2_agent_outputs row, kind 'artifact', product_id BY VALUE) — but a second call
|
|
1105
|
+
* re-inserting that same (kind, product_id) trips `unique(kind, product_id)` (23505):
|
|
1106
|
+
* that is the already-attributed case, so 23505 is treated as success (no warning)
|
|
1107
|
+
* and only OTHER failures surface as attribution_warning. Re-fetches with
|
|
1108
|
+
* ARTIFACT_COLUMNS and returns { artifact }.
|
|
1109
|
+
*/
|
|
1110
|
+
async function recordContextExplorationHandler(client, userId, session, args) {
|
|
1111
|
+
try {
|
|
1112
|
+
if (!session) {
|
|
1113
|
+
return errorResult('record_context_exploration needs an open work session — call begin_work first.');
|
|
1114
|
+
}
|
|
1115
|
+
if (!args.content || !args.content.trim())
|
|
1116
|
+
return errorResult('record_context_exploration requires content.');
|
|
1117
|
+
// Write/upsert the ONE canonical context document for the session's task via the
|
|
1118
|
+
// run-free RPC (it resolves the task from p_session and sets the context-write GUC
|
|
1119
|
+
// the INSERT guard requires). Returns the canonical artifact's id.
|
|
1120
|
+
const artifactId = await must(client.rpc('cliv2_record_context', { p_session: session.sessionId, p_content: args.content }));
|
|
1121
|
+
if (!artifactId)
|
|
1122
|
+
throw new Error('cliv2_record_context returned no artifact id.');
|
|
1123
|
+
// Best-effort session attribution (mirror create_artifact). The RPC UPSERTS the
|
|
1124
|
+
// same canonical row, so a repeat call re-inserts the same (kind, product_id) and
|
|
1125
|
+
// trips unique(kind, product_id) (23505) — that is "already attributed", not a
|
|
1126
|
+
// failure, so 23505 is swallowed silently; any OTHER error is surfaced.
|
|
1127
|
+
let attributionWarning;
|
|
1128
|
+
const { error: attrError } = await client.from('cliv2_agent_outputs').insert({
|
|
1129
|
+
user_id: userId,
|
|
1130
|
+
session_id: session.sessionId,
|
|
1131
|
+
kind: 'artifact',
|
|
1132
|
+
product_id: artifactId,
|
|
1133
|
+
});
|
|
1134
|
+
if (attrError && attrError.code !== '23505') {
|
|
1135
|
+
attributionWarning = `Context recorded, but recording session attribution failed: ${attrError.message}`;
|
|
1136
|
+
}
|
|
1137
|
+
// Re-fetch the (live) canonical context artifact so the result matches
|
|
1138
|
+
// get_task / create_artifact.
|
|
1139
|
+
const artifact = await must(client.from('artifacts').select(ARTIFACT_COLUMNS).eq('id', artifactId).is('deleted_at', null).maybeSingle());
|
|
1140
|
+
if (!artifact)
|
|
1141
|
+
throw new Error('Recorded context artifact could not be re-fetched.');
|
|
1142
|
+
return textResult(attributionWarning ? { artifact, attribution_warning: attributionWarning } : { artifact });
|
|
1143
|
+
}
|
|
1144
|
+
catch (err) {
|
|
1145
|
+
return errorResult(`record_context_exploration failed: ${err.message}`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Reserve repo-relative paths on a codebase (D5) so concurrent agents don't collide.
|
|
1150
|
+
* Session-anchored and run-free: every reservation is stamped with the open session's
|
|
1151
|
+
* id/task and lives in the owner-scoped `cliv2_work_reservations` table (no agent-run,
|
|
1152
|
+
* no fencing token). Reservations are ADVISORY — a conflicting write lease held by
|
|
1153
|
+
* another session is REPORTED, never forced or waited on.
|
|
1154
|
+
*
|
|
1155
|
+
* Per requested path, decide reserve vs conflict vs already-held against the ACTIVE
|
|
1156
|
+
* (`released_at is null`) reservations on this codebase+path:
|
|
1157
|
+
* - CONFLICT if any active row is by a DIFFERENT session AND at least one side is a
|
|
1158
|
+
* 'write' lease (write-write and write-read collide; read-read coexists). Record
|
|
1159
|
+
* `{ path, held_by_session_id }` and insert nothing.
|
|
1160
|
+
* - ALREADY-HELD if THIS session already holds an active reservation on that path —
|
|
1161
|
+
* return its existing id, no duplicate insert. Exception: a read→write UPGRADE (this
|
|
1162
|
+
* session holds a read lease and now asks for write) mutates the row's mode to
|
|
1163
|
+
* 'write' in place and reports it as RESERVED, so the stronger lease is recorded.
|
|
1164
|
+
* - otherwise RESERVE — insert this session's lease and return `{ id, path, mode }`.
|
|
1165
|
+
* Paths pass through verbatim; the DB CHECK rejects absolute paths, and that error is
|
|
1166
|
+
* surfaced cleanly by the try/catch. Requires an open session.
|
|
1167
|
+
*/
|
|
1168
|
+
async function reserveWorkPathsHandler(client, session, args) {
|
|
1169
|
+
try {
|
|
1170
|
+
if (!session) {
|
|
1171
|
+
return errorResult('reserve_work_paths needs an open work session — call begin_work first.');
|
|
1172
|
+
}
|
|
1173
|
+
const mode = args.mode ?? 'write';
|
|
1174
|
+
const reserved = [];
|
|
1175
|
+
const already_held = [];
|
|
1176
|
+
const conflicts = [];
|
|
1177
|
+
for (const path of args.paths) {
|
|
1178
|
+
// Active reservations on this codebase+path, across ALL of the user's sessions
|
|
1179
|
+
// (RLS scopes the read to the user's own rows).
|
|
1180
|
+
const rows = (await must(client
|
|
1181
|
+
.from('cliv2_work_reservations')
|
|
1182
|
+
.select('id,session_id,mode')
|
|
1183
|
+
.eq('git_remote_url', args.git_remote_url)
|
|
1184
|
+
.eq('path', path)
|
|
1185
|
+
.is('released_at', null))) ?? [];
|
|
1186
|
+
// Conflict FIRST: another session holds an active lease AND at least one side is
|
|
1187
|
+
// 'write' (write-write or write-read collide; read-read is fine).
|
|
1188
|
+
const conflict = rows.find((r) => r.session_id !== session.sessionId && (r.mode === 'write' || mode === 'write'));
|
|
1189
|
+
if (conflict) {
|
|
1190
|
+
conflicts.push({ path, held_by_session_id: conflict.session_id });
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
// Else this session already has an active lease here. A read→write UPGRADE must
|
|
1194
|
+
// record the write, else another session's later read would see no conflict and
|
|
1195
|
+
// be cleared to read a file this session is rewriting. Reaching this branch with
|
|
1196
|
+
// mode==='write' means the conflict check found NO other-session row on this path
|
|
1197
|
+
// (with mode==='write' the conflict OR is true for ANY other-session row), so no
|
|
1198
|
+
// other session holds it and upgrading in place is safe. A write→read downgrade or
|
|
1199
|
+
// a same-mode repeat stays already-held.
|
|
1200
|
+
const mine = rows.find((r) => r.session_id === session.sessionId);
|
|
1201
|
+
if (mine) {
|
|
1202
|
+
if (mine.mode === 'read' && mode === 'write') {
|
|
1203
|
+
await must(client
|
|
1204
|
+
.from('cliv2_work_reservations')
|
|
1205
|
+
.update({ mode: 'write' })
|
|
1206
|
+
.eq('id', mine.id)
|
|
1207
|
+
.eq('session_id', session.sessionId)
|
|
1208
|
+
.select('id')
|
|
1209
|
+
.single());
|
|
1210
|
+
reserved.push({ id: mine.id, path, mode: 'write' });
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
already_held.push({ id: mine.id, path });
|
|
1214
|
+
}
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
// Else reserve: free (or only compatible reads by others) — insert this
|
|
1218
|
+
// session's lease. user_id defaults to auth.uid() in the DB; don't set it.
|
|
1219
|
+
const inserted = await must(client
|
|
1220
|
+
.from('cliv2_work_reservations')
|
|
1221
|
+
.insert({
|
|
1222
|
+
session_id: session.sessionId,
|
|
1223
|
+
task_id: session.taskId,
|
|
1224
|
+
git_remote_url: args.git_remote_url,
|
|
1225
|
+
path,
|
|
1226
|
+
mode,
|
|
1227
|
+
})
|
|
1228
|
+
.select('id,path,mode')
|
|
1229
|
+
.single());
|
|
1230
|
+
if (!inserted)
|
|
1231
|
+
throw new Error('Reservation insert returned no row.');
|
|
1232
|
+
reserved.push({ id: inserted.id, path: inserted.path, mode: inserted.mode });
|
|
1233
|
+
}
|
|
1234
|
+
return textResult({ reserved, already_held, conflicts });
|
|
1235
|
+
}
|
|
1236
|
+
catch (err) {
|
|
1237
|
+
return errorResult(`reserve_work_paths failed: ${err.message}`);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Release path reservations by id (D5). Only THIS session's still-active reservations
|
|
1242
|
+
* are released: the UPDATE is scoped by `session_id` (plus RLS to the user) and
|
|
1243
|
+
* `released_at is null`, so ids belonging to another session, already-released rows, or
|
|
1244
|
+
* unknown ids are silently no-ops. Returns the ids actually released. Requires an open
|
|
1245
|
+
* session.
|
|
1246
|
+
*/
|
|
1247
|
+
async function releaseWorkPathsHandler(client, session, args) {
|
|
1248
|
+
try {
|
|
1249
|
+
if (!session) {
|
|
1250
|
+
return errorResult('release_work_paths needs an open work session — call begin_work first.');
|
|
1251
|
+
}
|
|
1252
|
+
const rows = (await must(client
|
|
1253
|
+
.from('cliv2_work_reservations')
|
|
1254
|
+
.update({ released_at: new Date().toISOString() })
|
|
1255
|
+
.in('id', args.reservation_ids)
|
|
1256
|
+
.eq('session_id', session.sessionId)
|
|
1257
|
+
.is('released_at', null)
|
|
1258
|
+
.select('id'))) ?? [];
|
|
1259
|
+
return textResult({ released: rows.map((r) => r.id) });
|
|
1260
|
+
}
|
|
1261
|
+
catch (err) {
|
|
1262
|
+
return errorResult(`release_work_paths failed: ${err.message}`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Hand off coordination of a task to another of the CALLER'S OWN agent sessions (D5). One
|
|
1267
|
+
* coordinator per task: the `cliv2_task_coordinators` row is keyed on (user_id, task_id)
|
|
1268
|
+
* and UPSERTED to point at the target session. The target is validated to be one of the
|
|
1269
|
+
* caller's own sessions first — `cliv2_agent_sessions` carries TWO select policies (owner
|
|
1270
|
+
* AND task-read, the latter org-visible via `app.can_access_task`), so an RLS-only read
|
|
1271
|
+
* would ALSO admit a teammate's session on a shared task (a confused-deputy seed whose
|
|
1272
|
+
* `on delete cascade` could later nuke the caller's coordinator row). The lookup is
|
|
1273
|
+
* therefore constrained to `user_id = userId`, not RLS alone. Run-free, owner-scoped
|
|
1274
|
+
* (user_id defaults to auth.uid()). Requires an open session.
|
|
1275
|
+
*/
|
|
1276
|
+
async function transferCoordinationHandler(client, userId, session, args) {
|
|
1277
|
+
try {
|
|
1278
|
+
if (!session) {
|
|
1279
|
+
return errorResult('transfer_coordination needs an open work session — call begin_work first.');
|
|
1280
|
+
}
|
|
1281
|
+
// The target must be one of the CALLER'S OWN sessions. RLS alone is insufficient
|
|
1282
|
+
// (the task-read policy would admit a teammate's session on a shared task), so pin
|
|
1283
|
+
// the read to user_id = userId.
|
|
1284
|
+
const target = await must(client
|
|
1285
|
+
.from('cliv2_agent_sessions')
|
|
1286
|
+
.select('id')
|
|
1287
|
+
.eq('id', args.target_session_id)
|
|
1288
|
+
.eq('user_id', userId)
|
|
1289
|
+
.maybeSingle());
|
|
1290
|
+
if (!target) {
|
|
1291
|
+
return errorResult('target_session_id is not a session you can coordinate to.');
|
|
1292
|
+
}
|
|
1293
|
+
const coordinator = await must(client
|
|
1294
|
+
.from('cliv2_task_coordinators')
|
|
1295
|
+
.upsert({
|
|
1296
|
+
task_id: args.task_id,
|
|
1297
|
+
session_id: args.target_session_id,
|
|
1298
|
+
updated_at: new Date().toISOString(),
|
|
1299
|
+
}, { onConflict: 'user_id,task_id' })
|
|
1300
|
+
.select('task_id,session_id,updated_at')
|
|
1301
|
+
.single());
|
|
1302
|
+
if (!coordinator)
|
|
1303
|
+
throw new Error('Coordinator upsert returned no row.');
|
|
1304
|
+
return textResult({ coordinator });
|
|
1305
|
+
}
|
|
1306
|
+
catch (err) {
|
|
1307
|
+
return errorResult(`transfer_coordination failed: ${err.message}`);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Record that a codebase's checkout is unavailable to this session (D5) — e.g. it isn't
|
|
1312
|
+
* cloned on this machine — with an optional reason. A plain owner-scoped insert into
|
|
1313
|
+
* `cliv2_unavailable_checkouts`, stamped with the open session's id (user_id defaults to
|
|
1314
|
+
* auth.uid()). Only a path-free git remote URL is stored — never a local filesystem path.
|
|
1315
|
+
* Requires an open session.
|
|
1316
|
+
*/
|
|
1317
|
+
async function acknowledgeUnavailableCheckoutHandler(client, session, args) {
|
|
1318
|
+
try {
|
|
1319
|
+
if (!session) {
|
|
1320
|
+
return errorResult('acknowledge_unavailable_checkout needs an open work session — call begin_work first.');
|
|
1321
|
+
}
|
|
1322
|
+
const row = await must(client
|
|
1323
|
+
.from('cliv2_unavailable_checkouts')
|
|
1324
|
+
.insert({
|
|
1325
|
+
session_id: session.sessionId,
|
|
1326
|
+
git_remote_url: args.git_remote_url,
|
|
1327
|
+
reason: args.reason ?? null,
|
|
1328
|
+
})
|
|
1329
|
+
.select('id,git_remote_url,reason,created_at')
|
|
1330
|
+
.single());
|
|
1331
|
+
if (!row)
|
|
1332
|
+
throw new Error('Acknowledgement insert returned no row.');
|
|
1333
|
+
return textResult({ acknowledgement: row });
|
|
1334
|
+
}
|
|
1335
|
+
catch (err) {
|
|
1336
|
+
return errorResult(`acknowledge_unavailable_checkout failed: ${err.message}`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
// ---------------------------------------------------------------------------
|
|
1340
|
+
// Credentials (feature 08, Phase 2) — agent MCP fetch. Read-only from the
|
|
1341
|
+
// CLI's point of view: `list_credentials` lists names + kinds through the
|
|
1342
|
+
// user-session client (RLS `credentials_select` scopes rows to the ones the
|
|
1343
|
+
// signed-in user created, in orgs they belong to), and `get_credential`
|
|
1344
|
+
// resolves a name case-insensitively then calls the SECURITY DEFINER
|
|
1345
|
+
// `reveal_credential` RPC — the ONLY read path for the secret value. The
|
|
1346
|
+
// secret goes into the tool RESULT and nowhere else: never console.log'd,
|
|
1347
|
+
// never cached, never written to disk.
|
|
1348
|
+
// ---------------------------------------------------------------------------
|
|
1349
|
+
/** The uniform access error, verbatim from the web RPCs
|
|
1350
|
+
* (20260723130000_credentials.sql) — a caller can never distinguish
|
|
1351
|
+
* "doesn't exist" from "not yours". */
|
|
1352
|
+
const CREDENTIAL_NOT_FOUND = 'credential not found or not accessible';
|
|
1353
|
+
/**
|
|
1354
|
+
* List the signed-in user's stored credentials — names + kinds ONLY, never
|
|
1355
|
+
* values, secret ids, or row ids (ui.html screen 7).
|
|
1356
|
+
*/
|
|
1357
|
+
async function listCredentialsHandler(client) {
|
|
1358
|
+
try {
|
|
1359
|
+
const rows = (await must(client.from('credentials').select('name, kind').order('name', { ascending: true }))) ?? [];
|
|
1360
|
+
return textResult(rows.map((row) => ({ name: row.name, kind: row.kind })));
|
|
1361
|
+
}
|
|
1362
|
+
catch (err) {
|
|
1363
|
+
return errorResult(`list_credentials failed: ${err.message}`);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Fetch one credential's decrypted value by name (ui.html screens 8–9).
|
|
1368
|
+
* The name match is case-insensitive, like the web's uniqueness rule
|
|
1369
|
+
* (`lower(name)`), and is done in JS over the caller's own RLS-visible rows
|
|
1370
|
+
* (row counts are tiny; no pattern-injection surface). Unknown / inaccessible
|
|
1371
|
+
* name → the exact uniform error above. On a match, `reveal_credential(p_id)`
|
|
1372
|
+
* returns `{secret, username}`; that maps to screen 8's shapes —
|
|
1373
|
+
* api_key → `{kind, secret}`, login → `{kind, username, password}`.
|
|
1374
|
+
*/
|
|
1375
|
+
async function getCredentialHandler(client, args) {
|
|
1376
|
+
let match;
|
|
1377
|
+
try {
|
|
1378
|
+
const rows = (await must(client
|
|
1379
|
+
.from('credentials')
|
|
1380
|
+
.select('id, name, kind, org_id, created_by')
|
|
1381
|
+
.order('created_at', { ascending: true }))) ?? [];
|
|
1382
|
+
const wanted = args.name.toLowerCase();
|
|
1383
|
+
// Uniqueness is per (org, creator): the caller can see two credentials
|
|
1384
|
+
// with the same lowercased name across two of their orgs, or within one
|
|
1385
|
+
// org (their own plus one shared to them by another creator). Never guess
|
|
1386
|
+
// which secret was meant. org_id/created_by are read only to word this
|
|
1387
|
+
// error; they never appear in tool output.
|
|
1388
|
+
const matches = rows.filter((row) => row.name.toLowerCase() === wanted);
|
|
1389
|
+
if (matches.length > 1) {
|
|
1390
|
+
const sameOrg = matches.every((row) => row.org_id === matches[0].org_id);
|
|
1391
|
+
return errorResult(sameOrg
|
|
1392
|
+
? `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`
|
|
1393
|
+
: `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`);
|
|
1394
|
+
}
|
|
1395
|
+
match = matches[0];
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
return errorResult(`get_credential failed: ${err.message}`);
|
|
1399
|
+
}
|
|
1400
|
+
if (!match)
|
|
1401
|
+
return errorResult(CREDENTIAL_NOT_FOUND);
|
|
1402
|
+
try {
|
|
1403
|
+
const revealed = await must(client.rpc('reveal_credential', { p_id: match.id }));
|
|
1404
|
+
if (!revealed)
|
|
1405
|
+
throw new Error(CREDENTIAL_NOT_FOUND);
|
|
1406
|
+
return textResult(match.kind === 'login'
|
|
1407
|
+
? { kind: 'login', username: revealed.username, password: revealed.secret }
|
|
1408
|
+
: { kind: 'api_key', secret: revealed.secret });
|
|
1409
|
+
}
|
|
1410
|
+
catch (err) {
|
|
1411
|
+
// The RPC's own message, verbatim — it is already uniform (the same
|
|
1412
|
+
// 'credential not found or not accessible' on any access failure).
|
|
1413
|
+
return errorResult(err.message);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
// ---------------------------------------------------------------------------
|
|
1417
|
+
// The twenty-two tools this server exposes. Exported for the self-check.
|
|
1418
|
+
// ---------------------------------------------------------------------------
|
|
1419
|
+
export const TOOL_NAMES = [
|
|
1420
|
+
'list_tasks',
|
|
1421
|
+
'get_task',
|
|
1422
|
+
'create_artifact',
|
|
1423
|
+
'update_task',
|
|
1424
|
+
'update_artifact',
|
|
1425
|
+
'set_task_role_slugs',
|
|
1426
|
+
'add_comment',
|
|
1427
|
+
'create_task',
|
|
1428
|
+
'begin_work',
|
|
1429
|
+
'end_work',
|
|
1430
|
+
'ask_question',
|
|
1431
|
+
'present_wireframes',
|
|
1432
|
+
'present_mocks',
|
|
1433
|
+
'resolve_feedback',
|
|
1434
|
+
'record_user_input',
|
|
1435
|
+
'record_context_exploration',
|
|
1436
|
+
'reserve_work_paths',
|
|
1437
|
+
'release_work_paths',
|
|
1438
|
+
'transfer_coordination',
|
|
1439
|
+
'acknowledge_unavailable_checkout',
|
|
1440
|
+
'list_credentials',
|
|
1441
|
+
'get_credential',
|
|
1442
|
+
];
|
|
1443
|
+
/** Build a per-session McpServer with the twenty-two tools. A fresh instance per
|
|
1444
|
+
* session is what makes `server.server.getClientVersion()` (populated during
|
|
1445
|
+
* that session's `initialize`) the right source for attribution — mirroring
|
|
1446
|
+
* v1's per-session `buildServer`. `connectionId` is this connection's key into
|
|
1447
|
+
* the module-level `openSessions` registry (D2b). */
|
|
1448
|
+
export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
1449
|
+
const server = new McpServer({ name: 'ctrl-spc', version: '0.1.0' });
|
|
1450
|
+
// The MCP connection's open work session lives in the module-level `openSessions`
|
|
1451
|
+
// registry, keyed by this connection's `connectionId` (D2b). One McpServer is
|
|
1452
|
+
// built per MCP connection (see startToolsServer's per-initialize buildToolsServer),
|
|
1453
|
+
// so `connectionId` identifies exactly this agent's session: begin_work sets the
|
|
1454
|
+
// entry; create_artifact / add_comment / create_task read it to attribute outputs;
|
|
1455
|
+
// end_work (and connection-close / logout) clear it; the presence heartbeat keeps
|
|
1456
|
+
// it fresh. Read the entry at call time so each tool sees the latest begin_work.
|
|
1457
|
+
// This connection's agent, derived from the `initialize` clientInfo.name once
|
|
1458
|
+
// it has been negotiated (/claude/i -> 'claude', /codex/i -> 'codex', else null).
|
|
1459
|
+
const attribution = () => attributionFromClientName(server.server.getClientVersion()?.name);
|
|
1460
|
+
server.registerTool('list_tasks', {
|
|
1461
|
+
description: 'List the tasks in your CTRL+SPC projects, with project identity. Optionally filter to one project.',
|
|
1462
|
+
inputSchema: { project_id: z.string().optional() },
|
|
1463
|
+
}, async (args) => {
|
|
1464
|
+
touchSession(connectionId);
|
|
1465
|
+
return listTasksHandler(client, args);
|
|
1466
|
+
});
|
|
1467
|
+
server.registerTool('get_task', {
|
|
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.',
|
|
1476
|
+
inputSchema: { id: z.string().describe('Task id') },
|
|
1477
|
+
}, async ({ id }) => {
|
|
1478
|
+
touchSession(connectionId);
|
|
1479
|
+
return getTaskHandler(client, { id });
|
|
1480
|
+
});
|
|
1481
|
+
server.registerTool('create_artifact', {
|
|
1482
|
+
description: 'Create a titled analysis/plan/spec/diagram/mock/wireframe on a task. It appears in the web UI, attributed to you.',
|
|
1483
|
+
inputSchema: {
|
|
1484
|
+
task_id: z.string(),
|
|
1485
|
+
type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe']),
|
|
1486
|
+
format: z.enum(['md', 'html', 'json', 'svg']).optional().describe('Defaults to md'),
|
|
1487
|
+
title: z.string().min(1).optional(),
|
|
1488
|
+
purpose_key: z
|
|
1489
|
+
.string()
|
|
1490
|
+
.min(1)
|
|
1491
|
+
.optional()
|
|
1492
|
+
.describe('Stable machine-readable identity for this artifact purpose'),
|
|
1493
|
+
content: z.string(),
|
|
1494
|
+
},
|
|
1495
|
+
}, async (args) => {
|
|
1496
|
+
touchSession(connectionId);
|
|
1497
|
+
return createArtifactHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1498
|
+
});
|
|
1499
|
+
server.registerTool('update_task', {
|
|
1500
|
+
description: 'Update a task you own — its status (backlog / in_progress / done), name, description, or due_date. ' +
|
|
1501
|
+
'Pass expected_revision from the most recent get_task for optimistic concurrency; on a conflict, ' +
|
|
1502
|
+
'call get_task again to re-read and retry. The change appears in the web board.',
|
|
1503
|
+
inputSchema: {
|
|
1504
|
+
id: z.string().describe('Task id'),
|
|
1505
|
+
expected_revision: z
|
|
1506
|
+
.number()
|
|
1507
|
+
.int()
|
|
1508
|
+
.positive()
|
|
1509
|
+
.describe('The task revision from the most recent get_task (optimistic concurrency)'),
|
|
1510
|
+
fields: z.object({
|
|
1511
|
+
status: z.enum(['backlog', 'in_progress', 'done']).optional(),
|
|
1512
|
+
name: z.string().optional(),
|
|
1513
|
+
description: z.string().optional(),
|
|
1514
|
+
due_date: z.string().nullable().optional().describe('ISO date, or null to clear'),
|
|
1515
|
+
}),
|
|
1516
|
+
},
|
|
1517
|
+
}, async (args) => {
|
|
1518
|
+
touchSession(connectionId);
|
|
1519
|
+
return updateTaskHandler(client, args);
|
|
1520
|
+
});
|
|
1521
|
+
server.registerTool('update_artifact', {
|
|
1522
|
+
description: 'Edit an artifact you can access — its title, content, type, or format. Pass expected_revision from the ' +
|
|
1523
|
+
'most recent get_task (optimistic concurrency); on a conflict, call get_task again and retry. ' +
|
|
1524
|
+
'The change appears in the web UI.',
|
|
1525
|
+
inputSchema: {
|
|
1526
|
+
id: z.string(),
|
|
1527
|
+
expected_revision: z
|
|
1528
|
+
.number()
|
|
1529
|
+
.int()
|
|
1530
|
+
.positive()
|
|
1531
|
+
.describe('The artifact revision from the most recent get_task (optimistic concurrency)'),
|
|
1532
|
+
fields: z.object({
|
|
1533
|
+
title: z.string().min(1).optional(),
|
|
1534
|
+
type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe']).optional(),
|
|
1535
|
+
format: z.enum(['md', 'html', 'json', 'svg']).optional(),
|
|
1536
|
+
content: z.string().optional(),
|
|
1537
|
+
}),
|
|
1538
|
+
},
|
|
1539
|
+
}, async (args) => {
|
|
1540
|
+
touchSession(connectionId);
|
|
1541
|
+
return updateArtifactHandler(client, args);
|
|
1542
|
+
});
|
|
1543
|
+
server.registerTool('set_task_role_slugs', {
|
|
1544
|
+
description: 'Set the repository role slugs on a task (its role assignments). Replaces the task\'s role_slugs with ' +
|
|
1545
|
+
'the given list. The change appears in the web UI.',
|
|
1546
|
+
inputSchema: {
|
|
1547
|
+
task_id: z.string(),
|
|
1548
|
+
role_slugs: z.array(z.string()),
|
|
1549
|
+
},
|
|
1550
|
+
}, async (args) => {
|
|
1551
|
+
touchSession(connectionId);
|
|
1552
|
+
return setTaskRoleSlugsHandler(client, args);
|
|
1553
|
+
});
|
|
1554
|
+
server.registerTool('add_comment', {
|
|
1555
|
+
description: 'Leave a note on a task. The comment appears in the web UI, authored by you.',
|
|
1556
|
+
inputSchema: {
|
|
1557
|
+
task_id: z.string().describe('Task id'),
|
|
1558
|
+
body: z.string().min(1),
|
|
1559
|
+
},
|
|
1560
|
+
}, async (args) => {
|
|
1561
|
+
touchSession(connectionId);
|
|
1562
|
+
return addCommentHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1563
|
+
});
|
|
1564
|
+
server.registerTool('create_task', {
|
|
1565
|
+
description: 'Create a new task in one of your projects — for splitting work or leaving follow-ups. ' +
|
|
1566
|
+
'It appears on the web board, owned by you.',
|
|
1567
|
+
inputSchema: {
|
|
1568
|
+
project_id: z.string().describe('Project id the task belongs to'),
|
|
1569
|
+
name: z.string().min(1),
|
|
1570
|
+
description: z.string().optional(),
|
|
1571
|
+
},
|
|
1572
|
+
}, async (args) => {
|
|
1573
|
+
touchSession(connectionId);
|
|
1574
|
+
return createTaskHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1575
|
+
});
|
|
1576
|
+
server.registerTool('begin_work', {
|
|
1577
|
+
description: 'Open a work session on a task so the outputs you create afterward are attributed to this run. ' +
|
|
1578
|
+
'Call it once, before create_artifact, for the task you are about to work. ' +
|
|
1579
|
+
'Requires a supported agent (Claude or Codex). Reusing the same task returns the existing session.',
|
|
1580
|
+
inputSchema: {
|
|
1581
|
+
task_id: z.string().describe('Task id you are about to work on'),
|
|
1582
|
+
model: z.string().optional().describe('Optional model identifier for this session (e.g. the agent model)'),
|
|
1583
|
+
},
|
|
1584
|
+
}, async (args) => {
|
|
1585
|
+
touchSession(connectionId);
|
|
1586
|
+
try {
|
|
1587
|
+
// begin_work needs a supported agent — the session's `provider` is a NOT
|
|
1588
|
+
// NULL, checked ('claude'|'codex') column. An unrecognized client can't
|
|
1589
|
+
// open one.
|
|
1590
|
+
const provider = attribution();
|
|
1591
|
+
if (!provider) {
|
|
1592
|
+
return errorResult('begin_work needs a supported agent (Claude or Codex); this client is not recognized as one.');
|
|
1593
|
+
}
|
|
1594
|
+
// The task must exist and be live under the user's RLS.
|
|
1595
|
+
const task = await must(client.from('tasks').select('id').eq('id', args.task_id).is('archived_at', null).maybeSingle());
|
|
1596
|
+
if (!task)
|
|
1597
|
+
return errorResult(`No task found for id "${args.task_id}".`);
|
|
1598
|
+
// Already have a session on THIS connection for THIS task: refresh its
|
|
1599
|
+
// liveness (and model, if newly provided) and reuse it — no second row.
|
|
1600
|
+
const prev = openSessions.get(connectionId);
|
|
1601
|
+
if (prev && prev.taskId === args.task_id) {
|
|
1602
|
+
const patch = { last_seen_at: new Date().toISOString() };
|
|
1603
|
+
if (args.model !== undefined)
|
|
1604
|
+
patch.model = args.model;
|
|
1605
|
+
const updated = await must(client
|
|
1606
|
+
.from('cliv2_agent_sessions')
|
|
1607
|
+
.update(patch)
|
|
1608
|
+
.eq('id', prev.sessionId)
|
|
1609
|
+
.select('id,task_id,provider,model,status')
|
|
1610
|
+
.single());
|
|
1611
|
+
if (!updated)
|
|
1612
|
+
throw new Error('Session update returned no row.');
|
|
1613
|
+
return textResult({ session: updated });
|
|
1614
|
+
}
|
|
1615
|
+
// Open the NEW session FIRST (FIX 3): if this insert throws, the registry
|
|
1616
|
+
// still points at the previous (valid) session rather than an ended one.
|
|
1617
|
+
// `prev`, if present here, is on a DIFFERENT task (same-task returned above).
|
|
1618
|
+
const session = await must(client
|
|
1619
|
+
.from('cliv2_agent_sessions')
|
|
1620
|
+
.insert({
|
|
1621
|
+
user_id: userId,
|
|
1622
|
+
machine_id: machineId,
|
|
1623
|
+
task_id: args.task_id,
|
|
1624
|
+
provider,
|
|
1625
|
+
model: args.model ?? null,
|
|
1626
|
+
status: 'active',
|
|
1627
|
+
})
|
|
1628
|
+
.select('id,task_id,provider,model,status')
|
|
1629
|
+
.single());
|
|
1630
|
+
if (!session)
|
|
1631
|
+
throw new Error('Session insert returned no row.');
|
|
1632
|
+
// The new session opened: if this connection was on a DIFFERENT task,
|
|
1633
|
+
// best-effort mark that PREVIOUS session ended so its board "working" chip
|
|
1634
|
+
// clears promptly. Best-effort — a failure must NOT fail begin_work (the new
|
|
1635
|
+
// session is the real result); the old row lapses via its own TTL/freshness.
|
|
1636
|
+
if (prev) {
|
|
1637
|
+
try {
|
|
1638
|
+
await must(client.from('cliv2_agent_sessions').update({ status: 'ended' }).eq('id', prev.sessionId));
|
|
1639
|
+
}
|
|
1640
|
+
catch (err) {
|
|
1641
|
+
console.warn(`begin_work: ending previous session failed: ${err.message}`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
// Record the new session in the registry keyed by connectionId, with a
|
|
1645
|
+
// fresh activity-TTL (extended by every subsequent tool call).
|
|
1646
|
+
openSessions.set(connectionId, {
|
|
1647
|
+
sessionId: session.id,
|
|
1648
|
+
taskId: session.task_id,
|
|
1649
|
+
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
1650
|
+
});
|
|
1651
|
+
return textResult({ session });
|
|
1652
|
+
}
|
|
1653
|
+
catch (err) {
|
|
1654
|
+
return errorResult(`begin_work failed: ${err.message}`);
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
server.registerTool('end_work', {
|
|
1658
|
+
description: 'Close the work session you opened with begin_work, when you finish the item. ' +
|
|
1659
|
+
'After this, the board no longer shows you as working it. Safe to call with no open session.',
|
|
1660
|
+
inputSchema: {},
|
|
1661
|
+
}, async () => {
|
|
1662
|
+
touchSession(connectionId);
|
|
1663
|
+
try {
|
|
1664
|
+
const s = openSessions.get(connectionId);
|
|
1665
|
+
if (!s) {
|
|
1666
|
+
return textResult({ ended: false, message: 'No open work session on this connection.' });
|
|
1667
|
+
}
|
|
1668
|
+
await must(client.from('cliv2_agent_sessions').update({ status: 'ended' }).eq('id', s.sessionId));
|
|
1669
|
+
openSessions.delete(connectionId);
|
|
1670
|
+
return textResult({ ended: true, session_id: s.sessionId });
|
|
1671
|
+
}
|
|
1672
|
+
catch (err) {
|
|
1673
|
+
return errorResult(`end_work failed: ${err.message}`);
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
1676
|
+
server.registerTool('ask_question', {
|
|
1677
|
+
description: "Ask the user a question about the work item you're on. It appears as a decision on the item in the web UI; " +
|
|
1678
|
+
'the user answers it there, and you read the answer back with get_task. Requires an open session (begin_work).',
|
|
1679
|
+
inputSchema: {
|
|
1680
|
+
category: z.string().min(1),
|
|
1681
|
+
context: z.string().min(1),
|
|
1682
|
+
question: z.string().min(1),
|
|
1683
|
+
answer_mode: z.enum(['free_text', 'single_select', 'multi_select']).optional(),
|
|
1684
|
+
options: z.array(z.string()).optional(),
|
|
1685
|
+
related_artifact_id: z.string().optional(),
|
|
1686
|
+
},
|
|
1687
|
+
}, async (args) => {
|
|
1688
|
+
touchSession(connectionId);
|
|
1689
|
+
return askQuestionHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
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
|
+
});
|
|
1817
|
+
server.registerTool('record_user_input', {
|
|
1818
|
+
description: "Record the user's answer to an open decision on your work item (e.g. an answer they gave you directly). " +
|
|
1819
|
+
'Updates the decision to decided; it then shows answered in the web UI.',
|
|
1820
|
+
inputSchema: {
|
|
1821
|
+
decision_id: z.string().describe('The decision id to answer'),
|
|
1822
|
+
answer: z.string().optional().describe('The free-text answer (for a free_text decision)'),
|
|
1823
|
+
selected_options: z
|
|
1824
|
+
.array(z.string())
|
|
1825
|
+
.optional()
|
|
1826
|
+
.describe('The chosen option(s) (for a single_select / multi_select decision)'),
|
|
1827
|
+
answer_note: z.string().optional().describe('Optional note alongside a choice answer'),
|
|
1828
|
+
},
|
|
1829
|
+
}, async (args) => {
|
|
1830
|
+
touchSession(connectionId);
|
|
1831
|
+
return recordUserInputHandler(client, openSessions.get(connectionId) ?? null, args);
|
|
1832
|
+
});
|
|
1833
|
+
server.registerTool('record_context_exploration', {
|
|
1834
|
+
description: "Record what you explored for this work item as its context document (the canonical 'Work item context' " +
|
|
1835
|
+
'for the item). Repeated calls update the same document. Appears in the web UI.',
|
|
1836
|
+
inputSchema: {
|
|
1837
|
+
content: z.string().min(1),
|
|
1838
|
+
},
|
|
1839
|
+
}, async (args) => {
|
|
1840
|
+
touchSession(connectionId);
|
|
1841
|
+
return recordContextExplorationHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1842
|
+
});
|
|
1843
|
+
server.registerTool('reserve_work_paths', {
|
|
1844
|
+
description: "Reserve repo-relative paths you're about to edit on a codebase, so concurrent agents don't collide. " +
|
|
1845
|
+
'Conflicts (a write lease another session holds) are reported, not forced. ' +
|
|
1846
|
+
'Paths must be repo-relative, never absolute.',
|
|
1847
|
+
inputSchema: {
|
|
1848
|
+
git_remote_url: z.string().min(1),
|
|
1849
|
+
paths: z.array(z.string().min(1)).min(1),
|
|
1850
|
+
mode: z.enum(['read', 'write']).optional().describe('Defaults to write'),
|
|
1851
|
+
},
|
|
1852
|
+
}, async (args) => {
|
|
1853
|
+
touchSession(connectionId);
|
|
1854
|
+
return reserveWorkPathsHandler(client, openSessions.get(connectionId) ?? null, args);
|
|
1855
|
+
});
|
|
1856
|
+
server.registerTool('release_work_paths', {
|
|
1857
|
+
description: 'Release path reservations you previously made (by id). Only your own active reservations are released.',
|
|
1858
|
+
inputSchema: {
|
|
1859
|
+
reservation_ids: z.array(z.string()).min(1),
|
|
1860
|
+
},
|
|
1861
|
+
}, async (args) => {
|
|
1862
|
+
touchSession(connectionId);
|
|
1863
|
+
return releaseWorkPathsHandler(client, openSessions.get(connectionId) ?? null, args);
|
|
1864
|
+
});
|
|
1865
|
+
server.registerTool('transfer_coordination', {
|
|
1866
|
+
description: 'Hand off coordination of a task to another of your agent sessions (by session id). One coordinator per task.',
|
|
1867
|
+
inputSchema: {
|
|
1868
|
+
task_id: z.string(),
|
|
1869
|
+
target_session_id: z.string(),
|
|
1870
|
+
},
|
|
1871
|
+
}, async (args) => {
|
|
1872
|
+
touchSession(connectionId);
|
|
1873
|
+
return transferCoordinationHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1874
|
+
});
|
|
1875
|
+
server.registerTool('acknowledge_unavailable_checkout', {
|
|
1876
|
+
description: "Record that a codebase's checkout is unavailable to you (e.g. not cloned on this machine), with an optional reason.",
|
|
1877
|
+
inputSchema: {
|
|
1878
|
+
git_remote_url: z.string().min(1),
|
|
1879
|
+
reason: z.string().optional(),
|
|
1880
|
+
},
|
|
1881
|
+
}, async (args) => {
|
|
1882
|
+
touchSession(connectionId);
|
|
1883
|
+
return acknowledgeUnavailableCheckoutHandler(client, openSessions.get(connectionId) ?? null, args);
|
|
1884
|
+
});
|
|
1885
|
+
server.registerTool('list_credentials', {
|
|
1886
|
+
description: 'List the credentials the user has stored in CTRL+SPC — the names (and kinds: api_key or login) you can ' +
|
|
1887
|
+
'fetch a value for with get_credential. Names and kinds only, never values.',
|
|
1888
|
+
inputSchema: {},
|
|
1889
|
+
}, async () => {
|
|
1890
|
+
touchSession(connectionId);
|
|
1891
|
+
return listCredentialsHandler(client);
|
|
1892
|
+
});
|
|
1893
|
+
server.registerTool('get_credential', {
|
|
1894
|
+
description: "Fetch a stored credential's value by name (case-insensitive): an api_key's secret, or a login's " +
|
|
1895
|
+
'username + password. The value is for runtime use in this session only — never write it into a file, ' +
|
|
1896
|
+
'commit, log, or code, and never echo it back to the user unless they explicitly ask for it.',
|
|
1897
|
+
inputSchema: {
|
|
1898
|
+
name: z.string().min(1).describe('Credential name, as listed by list_credentials'),
|
|
1899
|
+
},
|
|
1900
|
+
}, async (args) => {
|
|
1901
|
+
touchSession(connectionId);
|
|
1902
|
+
return getCredentialHandler(client, args);
|
|
1903
|
+
});
|
|
1904
|
+
return server;
|
|
1905
|
+
}
|
|
1906
|
+
let handle = null;
|
|
1907
|
+
// D2b: the open work session per live MCP connection, so the presence
|
|
1908
|
+
// heartbeat can keep it fresh and connection-close / logout can end it. Each
|
|
1909
|
+
// entry carries an activity-TTL `expiresAt` (ms epoch): begin_work sets it and
|
|
1910
|
+
// EVERY tool call extends it (touchSession), because a tool call proves the
|
|
1911
|
+
// agent is alive. The companion — not the agent — drives the heartbeat, and the
|
|
1912
|
+
// MCP SDK only fires transport.onclose on an explicit DELETE / server stop (NOT
|
|
1913
|
+
// on Ctrl-C / crash / kill / sleep). Without the TTL a dead agent would stay
|
|
1914
|
+
// "working" forever; heartbeatOpenSessions instead lapses an entry once its TTL
|
|
1915
|
+
// passes, flipping the row to 'ended' so the board chip clears.
|
|
1916
|
+
const openSessions = new Map();
|
|
1917
|
+
let toolsClient = null;
|
|
1918
|
+
/** Extend the activity-TTL of this connection's open session, if any. Called at
|
|
1919
|
+
* the very start of every tool callback — any tool call proves the agent is
|
|
1920
|
+
* alive, so its session should stay "working" for another TTL window. No-op when
|
|
1921
|
+
* no session is open. */
|
|
1922
|
+
function touchSession(connectionId) {
|
|
1923
|
+
const s = openSessions.get(connectionId);
|
|
1924
|
+
if (s)
|
|
1925
|
+
s.expiresAt = Date.now() + SESSION_TTL_MS;
|
|
1926
|
+
}
|
|
1927
|
+
/** Set (or replace) the module client the session-lifecycle helpers use, so a
|
|
1928
|
+
* token the presence loop rebuilt after a wedge/refresh flows into the session
|
|
1929
|
+
* heartbeat too (FIX 2 — the CLI-v1 stale-token root cause). Only takes effect
|
|
1930
|
+
* while the tools server is running; the per-connection tool handlers keep their
|
|
1931
|
+
* own captured client, which is acceptable — the session heartbeat is what must
|
|
1932
|
+
* stay healthy for the presence chip. */
|
|
1933
|
+
export function setToolsClient(client) {
|
|
1934
|
+
if (handle)
|
|
1935
|
+
toolsClient = client;
|
|
1936
|
+
}
|
|
1937
|
+
/** Keep every LIVE open session's last_seen_at fresh, and lapse EXPIRED ones to
|
|
1938
|
+
* 'ended', so the web presence chip stays lit while an agent works and clears
|
|
1939
|
+
* once it goes quiet past its TTL (finished / Ctrl-C'd / crashed / asleep).
|
|
1940
|
+
* Called from the presence heartbeat. Partitions the registry by `expiresAt`,
|
|
1941
|
+
* deletes expired entries locally, then does up to two batched best-effort
|
|
1942
|
+
* writes. `.eq('status','active')` keeps end_work / onclose races safe (never
|
|
1943
|
+
* re-touches or re-ends a row another path already ended). Never throws. */
|
|
1944
|
+
export async function heartbeatOpenSessions() {
|
|
1945
|
+
if (!toolsClient || openSessions.size === 0)
|
|
1946
|
+
return;
|
|
1947
|
+
const now = Date.now();
|
|
1948
|
+
const liveIds = [];
|
|
1949
|
+
const expiredIds = [];
|
|
1950
|
+
for (const [connectionId, s] of openSessions) {
|
|
1951
|
+
if (now < s.expiresAt) {
|
|
1952
|
+
liveIds.push(s.sessionId);
|
|
1953
|
+
}
|
|
1954
|
+
else {
|
|
1955
|
+
expiredIds.push(s.sessionId);
|
|
1956
|
+
openSessions.delete(connectionId);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
if (liveIds.length) {
|
|
1960
|
+
try {
|
|
1961
|
+
const { error } = await toolsClient
|
|
1962
|
+
.from('cliv2_agent_sessions')
|
|
1963
|
+
.update({ last_seen_at: new Date().toISOString() })
|
|
1964
|
+
.in('id', liveIds)
|
|
1965
|
+
.eq('status', 'active');
|
|
1966
|
+
if (error)
|
|
1967
|
+
throw error;
|
|
1968
|
+
}
|
|
1969
|
+
catch (err) {
|
|
1970
|
+
console.warn(`open-session heartbeat failed, will retry: ${err.message}`);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
if (expiredIds.length) {
|
|
1974
|
+
try {
|
|
1975
|
+
const { error } = await toolsClient
|
|
1976
|
+
.from('cliv2_agent_sessions')
|
|
1977
|
+
.update({ status: 'ended' })
|
|
1978
|
+
.in('id', expiredIds)
|
|
1979
|
+
.eq('status', 'active');
|
|
1980
|
+
if (error)
|
|
1981
|
+
throw error;
|
|
1982
|
+
}
|
|
1983
|
+
catch (err) {
|
|
1984
|
+
console.warn(`lapsing expired sessions failed: ${err.message}`);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
/** Mark ONE connection's open session ended (connection closed). Best-effort. */
|
|
1989
|
+
export async function endConnectionSession(connectionId) {
|
|
1990
|
+
const s = openSessions.get(connectionId);
|
|
1991
|
+
if (!toolsClient || !s) {
|
|
1992
|
+
openSessions.delete(connectionId);
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
openSessions.delete(connectionId);
|
|
1996
|
+
try {
|
|
1997
|
+
const { error } = await toolsClient
|
|
1998
|
+
.from('cliv2_agent_sessions')
|
|
1999
|
+
.update({ status: 'ended' })
|
|
2000
|
+
.eq('id', s.sessionId);
|
|
2001
|
+
if (error)
|
|
2002
|
+
throw error;
|
|
2003
|
+
}
|
|
2004
|
+
catch (err) {
|
|
2005
|
+
console.warn(`ending session on connection close failed: ${err.message}`);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
/** Mark ALL open sessions ended and clear the registry (server stop / logout). */
|
|
2009
|
+
export async function endAllOpenSessions() {
|
|
2010
|
+
if (openSessions.size === 0) {
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
const sessionIds = [...openSessions.values()].map((s) => s.sessionId);
|
|
2014
|
+
openSessions.clear();
|
|
2015
|
+
if (!toolsClient)
|
|
2016
|
+
return;
|
|
2017
|
+
try {
|
|
2018
|
+
const { error } = await toolsClient
|
|
2019
|
+
.from('cliv2_agent_sessions')
|
|
2020
|
+
.update({ status: 'ended' })
|
|
2021
|
+
.in('id', sessionIds);
|
|
2022
|
+
if (error)
|
|
2023
|
+
throw error;
|
|
2024
|
+
}
|
|
2025
|
+
catch (err) {
|
|
2026
|
+
console.warn(`ending all open sessions failed: ${err.message}`);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
async function readJsonBody(req) {
|
|
2030
|
+
const chunks = [];
|
|
2031
|
+
for await (const chunk of req)
|
|
2032
|
+
chunks.push(chunk);
|
|
2033
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
2034
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
2035
|
+
}
|
|
2036
|
+
/** Start the local tools server. No-op if already running. Binds loopback-only;
|
|
2037
|
+
* rejects if the port is taken (caller treats a failure as best-effort). */
|
|
2038
|
+
export async function startToolsServer(deps) {
|
|
2039
|
+
if (handle)
|
|
2040
|
+
return;
|
|
2041
|
+
// D2b: capture the client so the session-lifecycle helpers (heartbeat / end)
|
|
2042
|
+
// can reach Supabase without a live MCP connection in hand.
|
|
2043
|
+
toolsClient = deps.client;
|
|
2044
|
+
const port = TOOLS_SERVER_PORT;
|
|
2045
|
+
const token = mcpToken();
|
|
2046
|
+
const sessions = new Map();
|
|
2047
|
+
async function handleHttp(req, res) {
|
|
2048
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
2049
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
2050
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
2051
|
+
res.end(JSON.stringify({ status: 'ok', server: 'ctrl-spc', process_id: process.pid }));
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
if (url.pathname !== '/mcp') {
|
|
2055
|
+
res.writeHead(404).end('Not found');
|
|
2056
|
+
return;
|
|
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
|
+
}
|
|
2074
|
+
try {
|
|
2075
|
+
const sessionIdHeader = req.headers['mcp-session-id'];
|
|
2076
|
+
const sessionId = typeof sessionIdHeader === 'string' ? sessionIdHeader : undefined;
|
|
2077
|
+
const existing = sessionId ? sessions.get(sessionId) : undefined;
|
|
2078
|
+
if (existing) {
|
|
2079
|
+
await existing.handleRequest(req, res);
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
if (req.method !== 'POST') {
|
|
2083
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('Bad Request: no valid session ID provided');
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
const body = await readJsonBody(req);
|
|
2087
|
+
if (!isInitializeRequest(body)) {
|
|
2088
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('Bad Request: no valid session ID provided');
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
const transport = new StreamableHTTPServerTransport({
|
|
2092
|
+
sessionIdGenerator: () => randomUUID(),
|
|
2093
|
+
onsessioninitialized: (sid) => {
|
|
2094
|
+
sessions.set(sid, transport);
|
|
2095
|
+
},
|
|
2096
|
+
// Same DNS-rebinding guard as v1: reject any Host header that isn't the
|
|
2097
|
+
// loopback address we actually bound, so a page on a public domain that
|
|
2098
|
+
// re-resolves to 127.0.0.1 can't drive this server from the browser.
|
|
2099
|
+
enableDnsRebindingProtection: true,
|
|
2100
|
+
allowedHosts: [`localhost:${port}`, `127.0.0.1:${port}`],
|
|
2101
|
+
});
|
|
2102
|
+
// D2b: one connectionId per MCP connection, keying this connection's entry
|
|
2103
|
+
// in openSessions. Generated before building the server so begin_work and
|
|
2104
|
+
// onclose share the same key.
|
|
2105
|
+
const connectionId = randomUUID();
|
|
2106
|
+
transport.onclose = () => {
|
|
2107
|
+
const sid = transport.sessionId;
|
|
2108
|
+
if (sid)
|
|
2109
|
+
sessions.delete(sid);
|
|
2110
|
+
// Connection dropped: end this connection's open session (best-effort).
|
|
2111
|
+
void endConnectionSession(connectionId);
|
|
2112
|
+
};
|
|
2113
|
+
const server = buildToolsServer(deps.client, deps.userId, deps.machineId, connectionId);
|
|
2114
|
+
await server.connect(transport);
|
|
2115
|
+
await transport.handleRequest(req, res, body);
|
|
2116
|
+
}
|
|
2117
|
+
catch (err) {
|
|
2118
|
+
if (!res.headersSent) {
|
|
2119
|
+
res
|
|
2120
|
+
.writeHead(500, { 'Content-Type': 'application/json' })
|
|
2121
|
+
.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: err.message }, id: null }));
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
const httpServer = createHttpServer((req, res) => void handleHttp(req, res));
|
|
2126
|
+
// Bind loopback-only (never all interfaces) and turn the EADDRINUSE 'error'
|
|
2127
|
+
// race into a rejected promise so the caller stays best-effort.
|
|
2128
|
+
await new Promise((resolve, reject) => {
|
|
2129
|
+
const onError = (err) => {
|
|
2130
|
+
httpServer.off('listening', onListening);
|
|
2131
|
+
reject(err);
|
|
2132
|
+
};
|
|
2133
|
+
const onListening = () => {
|
|
2134
|
+
httpServer.off('error', onError);
|
|
2135
|
+
resolve();
|
|
2136
|
+
};
|
|
2137
|
+
httpServer.once('error', onError);
|
|
2138
|
+
httpServer.once('listening', onListening);
|
|
2139
|
+
httpServer.listen(port, '127.0.0.1');
|
|
2140
|
+
});
|
|
2141
|
+
handle = {
|
|
2142
|
+
async close() {
|
|
2143
|
+
await Promise.all([...sessions.values()].map((t) => t.close().catch(() => { })));
|
|
2144
|
+
sessions.clear();
|
|
2145
|
+
await new Promise((resolve, reject) => {
|
|
2146
|
+
httpServer.close((err) => (err ? reject(err) : resolve()));
|
|
2147
|
+
});
|
|
2148
|
+
},
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
/** Stop the tools server. Best-effort; safe to call when not running. */
|
|
2152
|
+
export async function stopToolsServer() {
|
|
2153
|
+
const h = handle;
|
|
2154
|
+
if (!h)
|
|
2155
|
+
return;
|
|
2156
|
+
handle = null;
|
|
2157
|
+
regStatus.claude = 'idle';
|
|
2158
|
+
regStatus.codex = 'idle';
|
|
2159
|
+
// D2b: flip every open work session to ended (this needs toolsClient) BEFORE
|
|
2160
|
+
// dropping the client reference, so the board's "working" chips clear on server
|
|
2161
|
+
// stop / logout; then release the client alongside the reg-status resets.
|
|
2162
|
+
await endAllOpenSessions();
|
|
2163
|
+
toolsClient = null;
|
|
2164
|
+
await h.close();
|
|
2165
|
+
}
|
|
2166
|
+
/** Whether the tools server is currently listening, and its fixed port. */
|
|
2167
|
+
export function toolsServerStatus() {
|
|
2168
|
+
return { running: handle !== null, port: TOOLS_SERVER_PORT };
|
|
2169
|
+
}
|
|
2170
|
+
const regStatus = { claude: 'idle', codex: 'idle' };
|
|
2171
|
+
/**
|
|
2172
|
+
* Per-agent SERIAL operation chain. register and unregister for the same agent
|
|
2173
|
+
* must never interleave. `runRegister` does a slow (~7s) `mcp remove` then
|
|
2174
|
+
* `mcp add`; a logout `unregisterFromX` issues its own `mcp remove`. With no
|
|
2175
|
+
* ordering, an in-flight register's `add` can land AFTER logout's `remove`,
|
|
2176
|
+
* re-writing a dead `ctrl-spc` entry into the agent config after the tools
|
|
2177
|
+
* server is already gone — the exact dangling entry this feature prevents.
|
|
2178
|
+
* Enqueuing every register/unregister onto this chain forces them to run in call
|
|
2179
|
+
* order, so a logout queued mid-register runs AFTER the register's `add`
|
|
2180
|
+
* completes → the entry ends up removed. Both queued ops never throw
|
|
2181
|
+
* (`runRegister` and `actualUnregister` catch internally), so the chain always
|
|
2182
|
+
* stays resolved.
|
|
2183
|
+
*/
|
|
2184
|
+
const opChain = {
|
|
2185
|
+
claude: Promise.resolve(),
|
|
2186
|
+
codex: Promise.resolve(),
|
|
2187
|
+
};
|
|
2188
|
+
const execFileAsync = promisify(execFile);
|
|
2189
|
+
/** Snapshot of each agent's registration lifecycle state (copy — callers can't
|
|
2190
|
+
* mutate the module's map). Drives the companion's connecting/connected/failed
|
|
2191
|
+
* badge (see badgeReason). */
|
|
2192
|
+
export function agentRegStatus() {
|
|
2193
|
+
return { ...regStatus };
|
|
2194
|
+
}
|
|
2195
|
+
/** True once the ctrl-spc server has been registered into Claude this run. */
|
|
2196
|
+
export function isClaudeRegistered() {
|
|
2197
|
+
return regStatus.claude === 'registered';
|
|
2198
|
+
}
|
|
2199
|
+
/** True once the ctrl-spc server has been registered into Codex this run. */
|
|
2200
|
+
export function isCodexRegistered() {
|
|
2201
|
+
return regStatus.codex === 'registered';
|
|
2202
|
+
}
|
|
2203
|
+
/** The remove-then-add itself, run in the background off a fire-and-forget
|
|
2204
|
+
* register call. Ignores the remove result (a not-found exits non-zero), then
|
|
2205
|
+
* runs the add: success → 'registered', any error/timeout → 'failed'. Never
|
|
2206
|
+
* throws — it's caught here and reflected only in regStatus. */
|
|
2207
|
+
async function runRegister(agent, bin, removeArgs, addArgs) {
|
|
2208
|
+
try {
|
|
2209
|
+
await execFileAsync(bin, removeArgs, { timeout: 15000 });
|
|
2210
|
+
}
|
|
2211
|
+
catch {
|
|
2212
|
+
/* not registered yet — fine */
|
|
2213
|
+
}
|
|
2214
|
+
try {
|
|
2215
|
+
await execFileAsync(bin, addArgs, { timeout: 15000 });
|
|
2216
|
+
regStatus[agent] = 'registered';
|
|
2217
|
+
}
|
|
2218
|
+
catch (err) {
|
|
2219
|
+
console.warn(`Could not register ctrl-spc tools with ${agent}: ${err.message}`);
|
|
2220
|
+
regStatus[agent] = 'failed';
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* Register the running tools server into Claude Code's user config. Non-blocking:
|
|
2225
|
+
* resolves the `claude` binary the same way agents.ts does, marks 'registering'
|
|
2226
|
+
* synchronously, then kicks off the async remove-then-add and returns immediately
|
|
2227
|
+
* (the CLI's ~7s latency runs off the event loop, so sign-in no longer freezes).
|
|
2228
|
+
* Best-effort — never throws. Registers the loopback `/mcp` endpoint (the only
|
|
2229
|
+
* path handleHttp serves the MCP protocol on). Only that loopback URL reaches the
|
|
2230
|
+
* LOCAL `~/.claude.json`; no absolute filesystem path and nothing cloud-bound is
|
|
2231
|
+
* written.
|
|
2232
|
+
*/
|
|
2233
|
+
export function registerWithClaude(port) {
|
|
2234
|
+
const bin = agentPath('claude');
|
|
2235
|
+
if (!bin) {
|
|
2236
|
+
regStatus.claude = 'failed';
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
// Double-kick guard: a register is already queued/in-flight for this agent, so
|
|
2240
|
+
// don't enqueue a second one. This makes the heartbeat retry (presence.ts) safe
|
|
2241
|
+
// to fire every tick — it can't stack a second `mcp add` while one is running.
|
|
2242
|
+
if (regStatus.claude === 'registering')
|
|
2243
|
+
return;
|
|
2244
|
+
regStatus.claude = 'registering';
|
|
2245
|
+
const target = `http://127.0.0.1:${port}/mcp?token=${mcpToken()}`;
|
|
2246
|
+
opChain.claude = opChain.claude.then(() => runRegister('claude', bin, ['mcp', 'remove', '--scope', 'user', 'ctrl-spc'], ['mcp', 'add', '--scope', 'user', '--transport', 'http', 'ctrl-spc', target]));
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Register the running tools server into Codex's config (Phase 2). Codex's
|
|
2250
|
+
* `mcp add` is global (no `--scope`) and takes `--url` for a streamable HTTP
|
|
2251
|
+
* server: `codex mcp add <name> --url <url>` / `codex mcp remove <name>`. Same
|
|
2252
|
+
* non-blocking, best-effort, idempotent contract as Claude's.
|
|
2253
|
+
*/
|
|
2254
|
+
export function registerWithCodex(port) {
|
|
2255
|
+
const bin = agentPath('codex');
|
|
2256
|
+
if (!bin) {
|
|
2257
|
+
regStatus.codex = 'failed';
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
// Double-kick guard — see registerWithClaude.
|
|
2261
|
+
if (regStatus.codex === 'registering')
|
|
2262
|
+
return;
|
|
2263
|
+
regStatus.codex = 'registering';
|
|
2264
|
+
const target = `http://127.0.0.1:${port}/mcp?token=${mcpToken()}`;
|
|
2265
|
+
opChain.codex = opChain.codex.then(() => runRegister('codex', bin, ['mcp', 'remove', 'ctrl-spc'], ['mcp', 'add', 'ctrl-spc', '--url', target]));
|
|
2266
|
+
}
|
|
2267
|
+
/** The actual `mcp remove` for logout cleanup, run ON the agent's op chain so it
|
|
2268
|
+
* can never interleave with an in-flight register's `add`. Best-effort — swallows
|
|
2269
|
+
* errors and always lands the status back at 'idle'. Never throws (keeps the
|
|
2270
|
+
* chain resolved). Claude's remove is user-scoped; Codex's is global. */
|
|
2271
|
+
async function actualUnregister(agent) {
|
|
2272
|
+
const bin = agentPath(agent);
|
|
2273
|
+
if (bin) {
|
|
2274
|
+
const removeArgs = agent === 'claude'
|
|
2275
|
+
? ['mcp', 'remove', '--scope', 'user', 'ctrl-spc']
|
|
2276
|
+
: ['mcp', 'remove', 'ctrl-spc'];
|
|
2277
|
+
try {
|
|
2278
|
+
await execFileAsync(bin, removeArgs, { timeout: 15000 });
|
|
2279
|
+
}
|
|
2280
|
+
catch {
|
|
2281
|
+
/* best-effort — a leftover entry is the pre-fix state */
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
regStatus[agent] = 'idle';
|
|
2285
|
+
}
|
|
2286
|
+
/**
|
|
2287
|
+
* Remove the ctrl-spc entry from Claude's user config (logout cleanup). Enqueues
|
|
2288
|
+
* the removal on the SAME per-agent op chain as register and returns that promise,
|
|
2289
|
+
* so a logout queued while a register is in flight runs AFTER the register's `add`
|
|
2290
|
+
* completes → the entry ends up removed (never re-written after the server is
|
|
2291
|
+
* gone). Best-effort — swallows errors and always lands the status back at 'idle',
|
|
2292
|
+
* so a logged-out machine leaves no dead ctrl-spc server that would show "failed to
|
|
2293
|
+
* connect" the next time the user runs an agent. If Claude isn't installed there's
|
|
2294
|
+
* nothing to remove; the status is reset regardless.
|
|
2295
|
+
*/
|
|
2296
|
+
export function unregisterFromClaude() {
|
|
2297
|
+
return (opChain.claude = opChain.claude.then(() => actualUnregister('claude')));
|
|
2298
|
+
}
|
|
2299
|
+
/** Remove the ctrl-spc entry from Codex's config (logout cleanup). Same op-chain
|
|
2300
|
+
* ordering, best-effort, reset-to-'idle' contract as Claude's. */
|
|
2301
|
+
export function unregisterFromCodex() {
|
|
2302
|
+
return (opChain.codex = opChain.codex.then(() => actualUnregister('codex')));
|
|
2303
|
+
}
|
|
2304
|
+
const CAP = { claude: 'Claude', codex: 'Codex' };
|
|
2305
|
+
/**
|
|
2306
|
+
* Decide the badge state from the live signals:
|
|
2307
|
+
* - not signed in → signed-out
|
|
2308
|
+
* - no supported agent installed → no-agent
|
|
2309
|
+
* - tools server not running → failed
|
|
2310
|
+
* - any installed agent still idle/registering → connecting (lists all installed)
|
|
2311
|
+
* - else any registered → connected (lists the registered ones)
|
|
2312
|
+
* - else (all installed failed) → failed
|
|
2313
|
+
*/
|
|
2314
|
+
export function badgeReason(input) {
|
|
2315
|
+
const server = 'ctrl-spc';
|
|
2316
|
+
const { signedIn, installed, serverRunning, status } = input;
|
|
2317
|
+
if (!signedIn)
|
|
2318
|
+
return { connected: false, reason: 'signed-out', agent: '', server };
|
|
2319
|
+
if (installed.length === 0)
|
|
2320
|
+
return { connected: false, reason: 'no-agent', agent: '', server };
|
|
2321
|
+
if (!serverRunning)
|
|
2322
|
+
return { connected: false, reason: 'failed', agent: '', server };
|
|
2323
|
+
const pending = installed.filter((a) => status[a] === 'registering' || status[a] === 'idle');
|
|
2324
|
+
const registered = installed.filter((a) => status[a] === 'registered');
|
|
2325
|
+
if (pending.length) {
|
|
2326
|
+
return { connected: false, reason: 'connecting', agent: installed.map((a) => CAP[a]).join(', '), server };
|
|
2327
|
+
}
|
|
2328
|
+
if (registered.length) {
|
|
2329
|
+
return { connected: true, reason: 'connected', agent: registered.map((a) => CAP[a]).join(', '), server };
|
|
2330
|
+
}
|
|
2331
|
+
return { connected: false, reason: 'failed', agent: '', server };
|
|
2332
|
+
}
|