@ctrl-spc/cli 1.1.14 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents.js +54 -0
- package/dist/collision.js +44 -0
- package/dist/commands/fetch.js +213 -0
- package/dist/commands/init.js +457 -0
- package/dist/commands/link.js +152 -0
- package/dist/commands/login.js +332 -0
- package/dist/commands/logout.js +14 -0
- package/dist/commands/run.js +622 -0
- package/dist/config.js +68 -0
- package/dist/env.js +5 -0
- package/dist/git.js +70 -0
- package/dist/index.js +56 -72
- package/dist/mcp.js +1732 -0
- package/dist/presence.js +62 -0
- package/dist/prompt.js +47 -0
- package/dist/protocol.js +117 -0
- package/dist/skills.js +148 -0
- package/dist/supabase.js +40 -10
- package/dist/topology.js +602 -0
- package/package.json +25 -23
- package/bin/ctrl-spc.js +0 -6
- package/dist/auth.js +0 -172
- package/dist/controlRequests.js +0 -154
- package/dist/daemon.js +0 -152
- package/dist/devices.js +0 -64
- package/dist/flagAssembler.js +0 -29
- package/dist/init.js +0 -79
- package/dist/launchd.js +0 -154
- package/dist/lifecycle.js +0 -37
- package/dist/mcpConfig.js +0 -12
- package/dist/repo.js +0 -52
- package/dist/session.js +0 -58
- package/dist/setup.js +0 -60
- package/dist/spawner.js +0 -138
- package/dist/windows-service.js +0 -142
- package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.d.ts +0 -24
- package/node_modules/@ctrl-spc/mcp-server/dist/claimResolver.js +0 -32
- package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.d.ts +0 -2
- package/node_modules/@ctrl-spc/mcp-server/dist/fingerprint.js +0 -21
- package/node_modules/@ctrl-spc/mcp-server/dist/index.d.ts +0 -1
- package/node_modules/@ctrl-spc/mcp-server/dist/index.js +0 -22
- package/node_modules/@ctrl-spc/mcp-server/package.json +0 -38
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,1732 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { PROTOCOL_FULL, PROTOCOL_SHORT } from './protocol.js';
|
|
8
|
+
import { MCP_PORT } from './env.js';
|
|
9
|
+
import { getMachineIdentity } from './config.js';
|
|
10
|
+
/**
|
|
11
|
+
* From the MCP `initialize` request's `clientInfo.name` (spec: "Agent
|
|
12
|
+
* attribution: from the MCP initialize request's clientInfo.name — map
|
|
13
|
+
* /claude/i -> 'claude', /codex/i -> 'codex', else null").
|
|
14
|
+
*/
|
|
15
|
+
export function attributionFromClientName(name) {
|
|
16
|
+
if (!name)
|
|
17
|
+
return null;
|
|
18
|
+
if (/claude/i.test(name))
|
|
19
|
+
return 'claude';
|
|
20
|
+
if (/codex/i.test(name))
|
|
21
|
+
return 'codex';
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const QUESTION_CATEGORIES = [
|
|
25
|
+
'clarification', 'reproduction', 'intent', 'dependency', 'checkout', 'collision', 'split',
|
|
26
|
+
];
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Query plumbing
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* Awaits a Supabase (or RPC) call and throws on error — callers catch once,
|
|
32
|
+
* at the handler boundary. Takes the query's result loosely typed (`unknown`
|
|
33
|
+
* data): the client used everywhere here is untyped (no generated `Database`
|
|
34
|
+
* generic, per Task 1's `getClient()`), so postgrest-js can't infer embedded
|
|
35
|
+
* relationships' cardinality (owner/sprint are to-one at runtime — confirmed
|
|
36
|
+
* against the live schema — but statically inferred as arrays without real
|
|
37
|
+
* schema types); the caller's explicit `T` is the source of truth for shape.
|
|
38
|
+
*/
|
|
39
|
+
async function must(query) {
|
|
40
|
+
const { data, error } = await query;
|
|
41
|
+
if (error)
|
|
42
|
+
throw new Error(error.message);
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
function textResult(payload) {
|
|
46
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
47
|
+
}
|
|
48
|
+
function errorResult(message) {
|
|
49
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
50
|
+
}
|
|
51
|
+
function relationOne(value) {
|
|
52
|
+
return Array.isArray(value) ? value[0] ?? null : value;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the broker's live project set from owner-private machine workspaces.
|
|
56
|
+
* A projectId is accepted only for legacy unit/direct-handler callers; the
|
|
57
|
+
* production broker never carries a cwd project forever.
|
|
58
|
+
*/
|
|
59
|
+
async function accessibleProjects(deps, machineId = deps.machineId) {
|
|
60
|
+
if (deps.projectId)
|
|
61
|
+
return [{ id: deps.projectId }];
|
|
62
|
+
if (!machineId)
|
|
63
|
+
return [];
|
|
64
|
+
const rows = (await must(deps.client
|
|
65
|
+
.from('machine_workspaces')
|
|
66
|
+
.select('project_id,project:projects!machine_workspaces_project_id_fkey(id,name)')
|
|
67
|
+
.eq('user_id', deps.userId)
|
|
68
|
+
.eq('machine_id', machineId))) ?? [];
|
|
69
|
+
const projects = new Map();
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
const project = relationOne(row.project);
|
|
72
|
+
projects.set(row.project_id, { id: row.project_id, ...(project?.name ? { name: project.name } : {}) });
|
|
73
|
+
}
|
|
74
|
+
return [...projects.values()].sort((left, right) => (left.name ?? left.id).localeCompare(right.name ?? right.id) || left.id.localeCompare(right.id));
|
|
75
|
+
}
|
|
76
|
+
function bindProject(deps, projectId) {
|
|
77
|
+
return { ...deps, projectId };
|
|
78
|
+
}
|
|
79
|
+
function requiredProjectId(deps, action) {
|
|
80
|
+
if (!deps.projectId)
|
|
81
|
+
throw new Error(`${action} requires a project-bound agent run`);
|
|
82
|
+
return deps.projectId;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* One future-proof task read model. `*` keeps every direct task column without
|
|
86
|
+
* requiring a CLI release when an additive migration lands. Every relationship
|
|
87
|
+
* uses the exact FK name, including relationships that are currently unique,
|
|
88
|
+
* so adding another FK can never make this query ambiguous later.
|
|
89
|
+
*/
|
|
90
|
+
const TASK_SELECT = '*,owner:profiles!tasks_owner_id_fkey(*),updater:profiles!tasks_updated_by_fkey(*),' +
|
|
91
|
+
'sprint:sprints!tasks_sprint_id_fkey(*),' +
|
|
92
|
+
'task_tags:task_tags!task_tags_task_id_fkey(*,tag:tags!task_tags_tag_id_fkey(*)),' +
|
|
93
|
+
'predecessor_edges:task_dependencies!task_dependencies_task_id_fkey(' +
|
|
94
|
+
'*,predecessor:tasks!task_dependencies_depends_on_task_id_fkey(*)),' +
|
|
95
|
+
'dependent_edges:task_dependencies!task_dependencies_depends_on_task_id_fkey(' +
|
|
96
|
+
'*,dependent:tasks!task_dependencies_task_id_fkey(*))';
|
|
97
|
+
const COMMENT_SELECT = '*,author:profiles!comments_author_id_fkey(*),agent_run:agent_runs!comments_agent_run_id_fkey(*)';
|
|
98
|
+
const ARTIFACT_SELECT = '*,creator:profiles!artifacts_created_by_fkey(*),updater:profiles!artifacts_updated_by_fkey(*),' +
|
|
99
|
+
'deleter:profiles!artifacts_deleted_by_fkey(*),agent_run:agent_runs!artifacts_agent_run_id_fkey(*),' +
|
|
100
|
+
'repository_coverage:artifact_repository_coverage!artifact_repository_coverage_artifact_id_fkey(' +
|
|
101
|
+
'*,repository_scope:repository_scopes!artifact_repository_coverage_repository_scope_id_fkey(*),' +
|
|
102
|
+
'acknowledgement:unavailable_checkout_acknowledgements!' +
|
|
103
|
+
'artifact_repository_coverage_acknowledgement_id_fkey(*))';
|
|
104
|
+
const TASK_VERSION_SELECT = '*,saved_by_profile:profiles!task_versions_saved_by_fkey(*),' +
|
|
105
|
+
'agent_run:agent_runs!task_versions_agent_run_id_fkey(*)';
|
|
106
|
+
const AGENT_RUN_SELECT = '*,user:profiles!agent_runs_user_id_fkey(*),machine:machines!agent_runs_machine_id_fkey(*)';
|
|
107
|
+
const TASK_CLAIM_SELECT = '*,agent_run:agent_runs!task_claims_agent_run_id_fkey(*)';
|
|
108
|
+
const WORK_RESERVATION_SELECT = '*,repository_scope:repository_scopes!work_reservations_repository_scope_id_fkey(*),' +
|
|
109
|
+
'repository_checkout:repository_checkouts!work_reservations_repository_checkout_id_fkey(*)';
|
|
110
|
+
const COLLABORATION_DOCUMENT_SELECT = '*,updated_by_profile:profiles!collaboration_documents_updated_by_fkey(*)';
|
|
111
|
+
function parseTaskRow(row) {
|
|
112
|
+
const { owner, updater = null, sprint, task_tags: taskTags, predecessor_edges: rawPredecessorEdges, dependent_edges: rawDependentEdges, task_dependencies: legacyDependencies, ...properties } = row;
|
|
113
|
+
const tagRecords = (taskTags ?? [])
|
|
114
|
+
.map((entry) => entry.tag ?? entry.tags ?? null)
|
|
115
|
+
.filter((tag) => Boolean(tag))
|
|
116
|
+
.sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id));
|
|
117
|
+
const predecessorEdges = [...(rawPredecessorEdges ?? legacyDependencies ?? [])]
|
|
118
|
+
.sort((left, right) => String(left.created_at ?? '').localeCompare(String(right.created_at ?? '')) ||
|
|
119
|
+
String(left.depends_on_task_id ?? left.predecessor?.id ?? left.depends_on?.id ?? '')
|
|
120
|
+
.localeCompare(String(right.depends_on_task_id ?? right.predecessor?.id ?? right.depends_on?.id ?? '')));
|
|
121
|
+
const dependentEdges = [...(rawDependentEdges ?? [])]
|
|
122
|
+
.sort((left, right) => String(left.created_at ?? '').localeCompare(String(right.created_at ?? '')) ||
|
|
123
|
+
String(left.task_id ?? left.dependent?.id ?? '').localeCompare(String(right.task_id ?? right.dependent?.id ?? '')));
|
|
124
|
+
const predecessors = predecessorEdges
|
|
125
|
+
.map((edge) => edge.predecessor ?? edge.depends_on ?? null)
|
|
126
|
+
.filter((dependency) => Boolean(dependency));
|
|
127
|
+
return {
|
|
128
|
+
...properties,
|
|
129
|
+
id: row.id,
|
|
130
|
+
name: row.name,
|
|
131
|
+
description: row.description,
|
|
132
|
+
due_date: row.due_date,
|
|
133
|
+
status: row.status,
|
|
134
|
+
position: row.position,
|
|
135
|
+
created_at: row.created_at,
|
|
136
|
+
revision: row.revision,
|
|
137
|
+
updated_at: row.updated_at,
|
|
138
|
+
updated_by: row.updated_by,
|
|
139
|
+
properties,
|
|
140
|
+
owner,
|
|
141
|
+
updater,
|
|
142
|
+
sprint,
|
|
143
|
+
tags: tagRecords.map((tag) => tag.name),
|
|
144
|
+
tag_records: tagRecords,
|
|
145
|
+
predecessor_edges: predecessorEdges,
|
|
146
|
+
dependent_edges: dependentEdges,
|
|
147
|
+
unmet_dependencies: predecessors
|
|
148
|
+
.filter((dependency) => dependency.status !== 'done')
|
|
149
|
+
.map((dependency) => ({
|
|
150
|
+
id: String(dependency.id),
|
|
151
|
+
name: String(dependency.name),
|
|
152
|
+
status: dependency.status,
|
|
153
|
+
})),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function recordArray(value) {
|
|
157
|
+
return Array.isArray(value)
|
|
158
|
+
? value.filter((item) => Boolean(item) && typeof item === 'object')
|
|
159
|
+
: [];
|
|
160
|
+
}
|
|
161
|
+
function sortRecords(rows, ...keys) {
|
|
162
|
+
return [...rows].sort((left, right) => {
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
const comparison = String(left[key] ?? '').localeCompare(String(right[key] ?? ''), undefined, { numeric: true });
|
|
165
|
+
if (comparison !== 0)
|
|
166
|
+
return comparison;
|
|
167
|
+
}
|
|
168
|
+
return 0;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
function normalizeComment(row) {
|
|
172
|
+
const body = typeof row.body === 'string' ? row.body : '';
|
|
173
|
+
const question = body.match(/^\[question:([a-z_]+)]\s+([\s\S]+)$/);
|
|
174
|
+
return {
|
|
175
|
+
...row,
|
|
176
|
+
...(question ? { question_category: question[1], question_text: question[2] } : {}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function normalizeArtifact(row) {
|
|
180
|
+
const repositoryCoverage = sortRecords(recordArray(row.repository_coverage), 'repository_scope_id', 'created_at');
|
|
181
|
+
return { ...row, repository_coverage: repositoryCoverage };
|
|
182
|
+
}
|
|
183
|
+
/** Resolves each name against the project's tags (case-insensitive), creating any that don't exist yet. */
|
|
184
|
+
async function resolveOrCreateTags(client, projectId, names) {
|
|
185
|
+
const wanted = [...new Set(names.map((n) => n.trim().toLowerCase()).filter(Boolean))];
|
|
186
|
+
if (wanted.length === 0)
|
|
187
|
+
return [];
|
|
188
|
+
const existing = (await must(client.from('tags').select('id,name').eq('project_id', projectId))) ?? [];
|
|
189
|
+
const byLower = new Map(existing.map((t) => [t.name.toLowerCase(), t]));
|
|
190
|
+
const missing = wanted.filter((n) => !byLower.has(n));
|
|
191
|
+
if (missing.length > 0) {
|
|
192
|
+
const created = (await must(client
|
|
193
|
+
.from('tags')
|
|
194
|
+
.insert(missing.map((name) => ({ project_id: projectId, name })))
|
|
195
|
+
.select('id,name'))) ?? [];
|
|
196
|
+
for (const tag of created)
|
|
197
|
+
byLower.set(tag.name.toLowerCase(), tag);
|
|
198
|
+
}
|
|
199
|
+
return wanted.map((n) => byLower.get(n)).filter((t) => Boolean(t));
|
|
200
|
+
}
|
|
201
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
202
|
+
/** `owner` fields accept either a profile id or an email (spec: "owner (email or profile id)"). */
|
|
203
|
+
async function resolveOwnerId(client, owner) {
|
|
204
|
+
if (UUID_RE.test(owner))
|
|
205
|
+
return owner;
|
|
206
|
+
const profile = await must(client.from('profiles').select('id').eq('email', owner).maybeSingle());
|
|
207
|
+
if (!profile)
|
|
208
|
+
throw new Error(`No profile found for owner "${owner}".`);
|
|
209
|
+
return profile.id;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Resolves a sprint by name (case-insensitive), creating one if it doesn't
|
|
213
|
+
* exist yet.
|
|
214
|
+
*
|
|
215
|
+
* ponytail: update_task's `sprint` field is name-only — no start/end dates —
|
|
216
|
+
* so a newly-created sprint defaults to a single-day window (today). Real
|
|
217
|
+
* date ranges are set from the web UI; this path only covers an agent
|
|
218
|
+
* referencing a sprint name that doesn't exist yet.
|
|
219
|
+
*/
|
|
220
|
+
async function resolveOrCreateSprint(client, projectId, name) {
|
|
221
|
+
const existing = (await must(client.from('sprints').select('id,name').eq('project_id', projectId))) ?? [];
|
|
222
|
+
const match = existing.find((s) => s.name.toLowerCase() === name.trim().toLowerCase());
|
|
223
|
+
if (match)
|
|
224
|
+
return match.id;
|
|
225
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
226
|
+
const created = await must(client.from('sprints').insert({ project_id: projectId, name, start_date: today, end_date: today }).select('id').single());
|
|
227
|
+
if (!created)
|
|
228
|
+
throw new Error('Sprint creation returned no row.');
|
|
229
|
+
return created.id;
|
|
230
|
+
}
|
|
231
|
+
async function loadAgentProjectTopology(client, projectId, userId, machineId) {
|
|
232
|
+
const project = await must(client.from('projects').select('topology_revision').eq('id', projectId).single());
|
|
233
|
+
if (!project)
|
|
234
|
+
throw new Error('Project topology could not be loaded.');
|
|
235
|
+
const scopeRows = (await must(client
|
|
236
|
+
.from('project_repository_scopes')
|
|
237
|
+
.select('repository_scope_id,purpose,required,position,' +
|
|
238
|
+
'scope:repository_scopes!project_repository_scopes_repository_scope_id_fkey(' +
|
|
239
|
+
'id,repository_id,scope_path,kind,name,' +
|
|
240
|
+
'repository:repositories!repository_scopes_repository_id_fkey(id,remote_key,name))')
|
|
241
|
+
.eq('project_id', projectId)
|
|
242
|
+
.eq('active', true)
|
|
243
|
+
.order('position', { ascending: true }))) ?? [];
|
|
244
|
+
const workspace = await must(client
|
|
245
|
+
.from('machine_workspaces')
|
|
246
|
+
.select('id,workspace_root')
|
|
247
|
+
.eq('user_id', userId)
|
|
248
|
+
.eq('machine_id', machineId)
|
|
249
|
+
.eq('project_id', projectId)
|
|
250
|
+
.maybeSingle());
|
|
251
|
+
const checkouts = workspace
|
|
252
|
+
? (await must(client
|
|
253
|
+
.from('repository_checkouts')
|
|
254
|
+
.select('id,repository_id,local_path,availability,verified_remote_key,head_sha,is_worktree,verified_at')
|
|
255
|
+
.eq('machine_workspace_id', workspace.id))) ?? []
|
|
256
|
+
: [];
|
|
257
|
+
const checkoutsByRepository = new Map();
|
|
258
|
+
for (const checkout of checkouts) {
|
|
259
|
+
const existing = checkoutsByRepository.get(checkout.repository_id) ?? [];
|
|
260
|
+
existing.push(checkout);
|
|
261
|
+
checkoutsByRepository.set(checkout.repository_id, existing);
|
|
262
|
+
}
|
|
263
|
+
const scopes = scopeRows.flatMap((row) => {
|
|
264
|
+
const scope = row.scope;
|
|
265
|
+
if (!scope?.repository)
|
|
266
|
+
return [];
|
|
267
|
+
const candidates = [...(checkoutsByRepository.get(scope.repository_id) ?? [])]
|
|
268
|
+
.sort((left, right) => Number(right.availability === 'available') - Number(left.availability === 'available') ||
|
|
269
|
+
left.local_path.localeCompare(right.local_path) || left.id.localeCompare(right.id));
|
|
270
|
+
const checkout = candidates.find((candidate) => candidate.availability === 'available') ?? candidates[0] ?? null;
|
|
271
|
+
const availability = checkout?.availability ?? 'missing';
|
|
272
|
+
return [{
|
|
273
|
+
repository_scope_id: scope.id,
|
|
274
|
+
repository_id: scope.repository.id,
|
|
275
|
+
repository_name: scope.repository.name,
|
|
276
|
+
remote_key: scope.repository.remote_key,
|
|
277
|
+
scope_name: scope.name,
|
|
278
|
+
scope_path: scope.scope_path,
|
|
279
|
+
scope_kind: scope.kind,
|
|
280
|
+
purpose: row.purpose,
|
|
281
|
+
required: row.required,
|
|
282
|
+
position: row.position,
|
|
283
|
+
checkouts: candidates.map((candidate) => ({
|
|
284
|
+
id: candidate.id,
|
|
285
|
+
local_path: candidate.local_path,
|
|
286
|
+
availability: candidate.availability,
|
|
287
|
+
verified_remote_key: candidate.verified_remote_key,
|
|
288
|
+
head_sha: candidate.head_sha,
|
|
289
|
+
is_worktree: candidate.is_worktree,
|
|
290
|
+
verified_at: candidate.verified_at,
|
|
291
|
+
})),
|
|
292
|
+
checkout: checkout
|
|
293
|
+
? {
|
|
294
|
+
id: checkout.id,
|
|
295
|
+
local_path: checkout.local_path,
|
|
296
|
+
availability,
|
|
297
|
+
verified_remote_key: checkout.verified_remote_key,
|
|
298
|
+
head_sha: checkout.head_sha,
|
|
299
|
+
is_worktree: checkout.is_worktree,
|
|
300
|
+
verified_at: checkout.verified_at,
|
|
301
|
+
}
|
|
302
|
+
: { id: null, local_path: null, availability: 'missing' },
|
|
303
|
+
fetch_offer: availability === 'available'
|
|
304
|
+
? null
|
|
305
|
+
: {
|
|
306
|
+
available: Boolean(scope.repository.remote_key),
|
|
307
|
+
instruction: 'Warn the user that this repository is unavailable locally and offer to fetch it. Never fetch without approval.',
|
|
308
|
+
},
|
|
309
|
+
}];
|
|
310
|
+
});
|
|
311
|
+
const missingRequiredScopeIds = scopes
|
|
312
|
+
.filter((scope) => scope.required && scope.checkout.availability !== 'available')
|
|
313
|
+
.map((scope) => scope.repository_scope_id);
|
|
314
|
+
return {
|
|
315
|
+
revision: Number(project.topology_revision),
|
|
316
|
+
workspace_root: workspace?.workspace_root ?? null,
|
|
317
|
+
scopes,
|
|
318
|
+
complete: missingRequiredScopeIds.length === 0,
|
|
319
|
+
missing_required_scope_ids: missingRequiredScopeIds,
|
|
320
|
+
instruction: missingRequiredScopeIds.length === 0
|
|
321
|
+
? 'Inspect every repository scope before planning or implementation; reserve only the paths you will edit.'
|
|
322
|
+
: 'Required repositories are unavailable on this machine. Warn the user and offer to fetch them before claiming they were reviewed.',
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function coordinationError(action, err) {
|
|
326
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
327
|
+
if (/stale project topology revision|claim was created against a stale project topology|project repository topology changed/i.test(raw)) {
|
|
328
|
+
return errorResult(`${action}: the project's repository topology changed after this agent began. ` +
|
|
329
|
+
'Do not edit. End this run, fetch the task and repository list again, then begin a fresh run.');
|
|
330
|
+
}
|
|
331
|
+
if (/lease is not active|lease has expired|agent run is not active|stale agent lease generation|stale or missing task claim fencing token/i.test(raw)) {
|
|
332
|
+
return errorResult(`${action}: this agent no longer owns an active lease. Stop editing and call begin_work again before continuing.`);
|
|
333
|
+
}
|
|
334
|
+
return errorResult(`${action} failed: ${raw}`);
|
|
335
|
+
}
|
|
336
|
+
function runOrError(session, action) {
|
|
337
|
+
if (session.run)
|
|
338
|
+
return session.run;
|
|
339
|
+
return errorResult(`${action} requires an active agent run. Call begin_work for the task before editing or writing back.`);
|
|
340
|
+
}
|
|
341
|
+
function joinedRunOrError(session, action) {
|
|
342
|
+
const run = runOrError(session, action);
|
|
343
|
+
if ('content' in run)
|
|
344
|
+
return run;
|
|
345
|
+
if (run.state === 'joined')
|
|
346
|
+
return run;
|
|
347
|
+
return errorResult(`${action} is blocked while required repositories are unavailable. ` +
|
|
348
|
+
'Warn the user and offer to fetch them, or acknowledge each unavailable checkout, then call begin_work again.');
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Begins one durable run for this MCP connection. Calling begin_work is the
|
|
352
|
+
* explicit collaboration action: the first live run coordinates; later runs
|
|
353
|
+
* join as collaborators but still may not edit until path reservation succeeds.
|
|
354
|
+
*/
|
|
355
|
+
export async function beginAgentRunHandler(deps, session, args) {
|
|
356
|
+
try {
|
|
357
|
+
const previousExploration = session.exploration;
|
|
358
|
+
if (!args.task_id)
|
|
359
|
+
return errorResult('begin_work requires task_id.');
|
|
360
|
+
if (session.run && session.run.taskId !== args.task_id) {
|
|
361
|
+
return errorResult(`This agent is already working on task ${session.run.taskId}. ` +
|
|
362
|
+
'Call end_work before beginning a different task in the same agent session.');
|
|
363
|
+
}
|
|
364
|
+
const task = deps.projectId
|
|
365
|
+
? await must(deps.client.from('tasks').select('id,project_id').eq('id', args.task_id).eq('project_id', deps.projectId).maybeSingle())
|
|
366
|
+
: await must(deps.client.from('tasks').select('id,project_id').eq('id', args.task_id).maybeSingle());
|
|
367
|
+
if (!task)
|
|
368
|
+
return errorResult(`No accessible task found for id "${args.task_id}".`);
|
|
369
|
+
const taskProjectId = task.project_id ?? deps.projectId;
|
|
370
|
+
if (!taskProjectId)
|
|
371
|
+
throw new Error('task resolution returned no project id');
|
|
372
|
+
const projects = await accessibleProjects(deps, session.machineId);
|
|
373
|
+
if (!projects.some((project) => project.id === taskProjectId)) {
|
|
374
|
+
return errorResult(`Task "${args.task_id}" belongs to a project that is not initialized on this machine. ` +
|
|
375
|
+
'Initialize or link that project before beginning work.');
|
|
376
|
+
}
|
|
377
|
+
const result = await must(deps.client.rpc('begin_agent_run', {
|
|
378
|
+
p_task: args.task_id,
|
|
379
|
+
p_machine: session.machineId,
|
|
380
|
+
p_session: session.sessionId,
|
|
381
|
+
p_agent_kind: session.agentKind || 'unknown',
|
|
382
|
+
p_client_name: session.clientName,
|
|
383
|
+
p_client_version: session.clientVersion,
|
|
384
|
+
}));
|
|
385
|
+
if (!result)
|
|
386
|
+
throw new Error('begin_agent_run returned no lifecycle data');
|
|
387
|
+
const runProjectId = result.project_id ?? taskProjectId;
|
|
388
|
+
if (runProjectId !== taskProjectId) {
|
|
389
|
+
throw new Error('begin_agent_run returned a project that does not match the requested task');
|
|
390
|
+
}
|
|
391
|
+
if (result.status === 'missing_checkouts') {
|
|
392
|
+
session.exploration = null;
|
|
393
|
+
session.run = {
|
|
394
|
+
state: 'waiting',
|
|
395
|
+
runId: result.run_id,
|
|
396
|
+
taskId: args.task_id,
|
|
397
|
+
projectId: runProjectId,
|
|
398
|
+
topologyRevision: Number(result.topology_revision),
|
|
399
|
+
leaseGeneration: Number(result.lease_generation),
|
|
400
|
+
leaseExpiresAt: result.lease_expires_at,
|
|
401
|
+
missingRepositories: result.missing_repositories ?? [],
|
|
402
|
+
choices: result.choices ?? ['fetch', 'acknowledge_unavailable'],
|
|
403
|
+
};
|
|
404
|
+
return textResult({
|
|
405
|
+
coordination: {
|
|
406
|
+
status: 'missing_checkouts',
|
|
407
|
+
role: 'waiting',
|
|
408
|
+
repository_edits_allowed: false,
|
|
409
|
+
mutations_allowed: false,
|
|
410
|
+
instruction: 'Required repositories are unavailable on this machine. Warn the user and offer to fetch each repository. ' +
|
|
411
|
+
'If the user chooses to continue without one, call acknowledge_unavailable_checkout for that scope. ' +
|
|
412
|
+
'After every missing repository is fetched or acknowledged, call begin_work again to obtain a joined claim.',
|
|
413
|
+
choices: session.run.choices,
|
|
414
|
+
},
|
|
415
|
+
missing_repositories: session.run.missingRepositories,
|
|
416
|
+
agent_run: session.run,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
session.run = {
|
|
420
|
+
state: 'joined',
|
|
421
|
+
runId: result.run_id,
|
|
422
|
+
taskId: args.task_id,
|
|
423
|
+
projectId: runProjectId,
|
|
424
|
+
claimId: result.claim_id,
|
|
425
|
+
role: result.role,
|
|
426
|
+
fencingToken: Number(result.fencing_token),
|
|
427
|
+
topologyRevision: Number(result.topology_revision),
|
|
428
|
+
leaseGeneration: Number(result.lease_generation),
|
|
429
|
+
leaseExpiresAt: result.lease_expires_at,
|
|
430
|
+
};
|
|
431
|
+
session.exploration = previousExploration?.agentRunId === result.run_id &&
|
|
432
|
+
previousExploration.taskId === args.task_id &&
|
|
433
|
+
previousExploration.topologyRevision === Number(result.topology_revision)
|
|
434
|
+
? previousExploration
|
|
435
|
+
: null;
|
|
436
|
+
const collaborator = result.role === 'collaborator';
|
|
437
|
+
return textResult({
|
|
438
|
+
coordination: {
|
|
439
|
+
status: collaborator ? 'joined_as_collaborator' : 'coordinating',
|
|
440
|
+
role: result.role,
|
|
441
|
+
another_agent_is_active: collaborator,
|
|
442
|
+
repository_edits_allowed: false,
|
|
443
|
+
instruction: collaborator
|
|
444
|
+
? 'Another agent is coordinating this task. Inspect the full repository topology, agree on a disjoint scope, and call reserve_work_paths before editing. A collision response means you must narrow, wait, request handoff, or use an isolated worktree.'
|
|
445
|
+
: 'You are coordinating this task. Inspect every repository, then call reserve_work_paths before editing. Collaborators may join with disjoint reservations.',
|
|
446
|
+
},
|
|
447
|
+
agent_run: session.run,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
return coordinationError('begin_work', err);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
export async function heartbeatAgentRunHandler(deps, session) {
|
|
455
|
+
const active = runOrError(session, 'heartbeat_work');
|
|
456
|
+
if ('content' in active)
|
|
457
|
+
return active;
|
|
458
|
+
try {
|
|
459
|
+
const result = await must(deps.client.rpc('heartbeat_agent_run', {
|
|
460
|
+
p_run: active.runId,
|
|
461
|
+
p_lease_generation: active.leaseGeneration,
|
|
462
|
+
}));
|
|
463
|
+
if (!result)
|
|
464
|
+
throw new Error('heartbeat_agent_run returned no lease data');
|
|
465
|
+
active.leaseGeneration = Number(result.lease_generation);
|
|
466
|
+
active.leaseExpiresAt = result.lease_expires_at;
|
|
467
|
+
return textResult({ agent_run: active });
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
return coordinationError('heartbeat_work', err);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
export async function reserveWorkPathsHandler(deps, session, args) {
|
|
474
|
+
const active = joinedRunOrError(session, 'reserve_work_paths');
|
|
475
|
+
if ('content' in active)
|
|
476
|
+
return active;
|
|
477
|
+
if (!Number.isInteger(args.topology_revision) || args.topology_revision < 1) {
|
|
478
|
+
return errorResult('reserve_work_paths requires topology_revision from begin_work.');
|
|
479
|
+
}
|
|
480
|
+
if (args.topology_revision !== active.topologyRevision) {
|
|
481
|
+
return errorResult(`reserve_work_paths topology mismatch: this run began at revision ${active.topologyRevision}, ` +
|
|
482
|
+
`but the request used ${args.topology_revision}. Stop editing and refresh the project topology.`);
|
|
483
|
+
}
|
|
484
|
+
if (!Array.isArray(args.reservations) || args.reservations.length === 0) {
|
|
485
|
+
return errorResult('reserve_work_paths requires at least one repository/path reservation.');
|
|
486
|
+
}
|
|
487
|
+
const missingCheckout = args.reservations.find((reservation) => (reservation.mode ?? 'write') === 'write' && !reservation.repository_checkout_id);
|
|
488
|
+
if (missingCheckout) {
|
|
489
|
+
return errorResult('Every write reservation requires repository_checkout_id from get_task topology.checkouts. ' +
|
|
490
|
+
'Choose the exact checkout or isolated worktree before editing.');
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
const result = await must(deps.client.rpc('reserve_work_paths', {
|
|
494
|
+
p_run: active.runId,
|
|
495
|
+
p_topology_revision: args.topology_revision,
|
|
496
|
+
p_reservations: args.reservations,
|
|
497
|
+
}));
|
|
498
|
+
if (!result)
|
|
499
|
+
throw new Error('reserve_work_paths returned no result');
|
|
500
|
+
if (!result.accepted) {
|
|
501
|
+
return textResult({
|
|
502
|
+
coordination: {
|
|
503
|
+
status: 'collision',
|
|
504
|
+
repository_edits_allowed: false,
|
|
505
|
+
instruction: 'Do not edit the conflicting path. Narrow the scope, wait, request a handoff, or work in an isolated worktree before retrying.',
|
|
506
|
+
choices: result.choices ?? ['narrow_scope', 'wait', 'handoff', 'isolated_worktree'],
|
|
507
|
+
},
|
|
508
|
+
conflicts: result.conflicts ?? [],
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return textResult({
|
|
512
|
+
coordination: {
|
|
513
|
+
status: 'reserved',
|
|
514
|
+
repository_edits_allowed: true,
|
|
515
|
+
instruction: 'Edit only the reserved repository paths. Release them or end_work when finished.',
|
|
516
|
+
},
|
|
517
|
+
reservations: result.reservations ?? [],
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
return coordinationError('reserve_work_paths', err);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
export async function releaseWorkPathsHandler(deps, session, args) {
|
|
525
|
+
const active = joinedRunOrError(session, 'release_work_paths');
|
|
526
|
+
if ('content' in active)
|
|
527
|
+
return active;
|
|
528
|
+
try {
|
|
529
|
+
const count = await must(deps.client.rpc('release_work_paths', {
|
|
530
|
+
p_run: active.runId,
|
|
531
|
+
p_reservation_ids: args.reservation_ids ?? null,
|
|
532
|
+
}));
|
|
533
|
+
return textResult({ released_count: Number(count ?? 0), agent_run_id: active.runId });
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
return coordinationError('release_work_paths', err);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
export async function transferCoordinationHandler(deps, session, args) {
|
|
540
|
+
const active = joinedRunOrError(session, 'transfer_coordination');
|
|
541
|
+
if ('content' in active)
|
|
542
|
+
return active;
|
|
543
|
+
if (active.role !== 'coordinator') {
|
|
544
|
+
return errorResult('transfer_coordination is available only to the current task coordinator.');
|
|
545
|
+
}
|
|
546
|
+
if (!args.target_run_id?.trim())
|
|
547
|
+
return errorResult('transfer_coordination requires target_run_id.');
|
|
548
|
+
try {
|
|
549
|
+
const result = await must(deps.client.rpc('transfer_task_coordination', {
|
|
550
|
+
p_run: active.runId,
|
|
551
|
+
p_target_run: args.target_run_id.trim(),
|
|
552
|
+
}));
|
|
553
|
+
if (!result)
|
|
554
|
+
throw new Error('transfer_task_coordination returned no result');
|
|
555
|
+
active.role = 'collaborator';
|
|
556
|
+
return textResult({
|
|
557
|
+
coordination: {
|
|
558
|
+
status: 'transferred',
|
|
559
|
+
role: 'collaborator',
|
|
560
|
+
coordinator_run_id: args.target_run_id.trim(),
|
|
561
|
+
instruction: 'You are now a collaborator. Keep only disjoint path reservations and coordinate before changing scope.',
|
|
562
|
+
},
|
|
563
|
+
transfer: result,
|
|
564
|
+
agent_run: active,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
return coordinationError('transfer_coordination', err);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
export async function acknowledgeUnavailableCheckoutHandler(deps, session, args) {
|
|
572
|
+
const active = runOrError(session, 'acknowledge_unavailable_checkout');
|
|
573
|
+
if ('content' in active)
|
|
574
|
+
return active;
|
|
575
|
+
if (!args.reason?.trim())
|
|
576
|
+
return errorResult('acknowledge_unavailable_checkout requires a clear reason.');
|
|
577
|
+
try {
|
|
578
|
+
const acknowledgementId = await must(deps.client.rpc('acknowledge_unavailable_checkout', {
|
|
579
|
+
p_run: active.runId,
|
|
580
|
+
p_repository_scope: args.repository_scope_id,
|
|
581
|
+
p_reason: args.reason.trim(),
|
|
582
|
+
}));
|
|
583
|
+
if (!acknowledgementId)
|
|
584
|
+
throw new Error('acknowledge_unavailable_checkout returned no id');
|
|
585
|
+
return textResult({
|
|
586
|
+
acknowledgement_id: acknowledgementId,
|
|
587
|
+
repository_scope_id: args.repository_scope_id,
|
|
588
|
+
instruction: 'This repository remains unavailable and must be marked unavailable_acknowledged in artifact coverage. ' +
|
|
589
|
+
'Call begin_work again after every missing repository is fetched or acknowledged; acknowledgement alone does not grant a claim.',
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
catch (err) {
|
|
593
|
+
return coordinationError('acknowledge_unavailable_checkout', err);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
async function activeRepositoryScopes(client, projectId) {
|
|
597
|
+
const rows = (await must(client
|
|
598
|
+
.from('project_repository_scopes')
|
|
599
|
+
.select('repository_scope_id,position,created_at')
|
|
600
|
+
.eq('project_id', projectId)
|
|
601
|
+
.eq('active', true)
|
|
602
|
+
.order('position', { ascending: true })
|
|
603
|
+
.order('created_at', { ascending: true })
|
|
604
|
+
.order('repository_scope_id', { ascending: true }))) ?? [];
|
|
605
|
+
return rows;
|
|
606
|
+
}
|
|
607
|
+
function exactScopeSet(expected, received, label) {
|
|
608
|
+
const unique = new Set(received);
|
|
609
|
+
if (unique.size !== received.length)
|
|
610
|
+
return `${label} assigns at least one repository scope more than once.`;
|
|
611
|
+
const expectedSet = new Set(expected);
|
|
612
|
+
const missing = expected.filter((id) => !unique.has(id));
|
|
613
|
+
const extra = received.filter((id) => !expectedSet.has(id));
|
|
614
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
615
|
+
return `${label} must include every active repository scope exactly once. ` +
|
|
616
|
+
`Missing: ${missing.join(', ') || 'none'}. Extra: ${extra.join(', ') || 'none'}.`;
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
function explorationArtifactContent(args, orderedCoverage) {
|
|
621
|
+
const lines = [
|
|
622
|
+
'# Context exploration',
|
|
623
|
+
'',
|
|
624
|
+
`Topology revision: ${args.topology_revision}`,
|
|
625
|
+
`Status: ${args.blocked_reason ? 'blocked — read-only subagents unavailable' : 'complete'}`,
|
|
626
|
+
];
|
|
627
|
+
if (args.summary?.trim())
|
|
628
|
+
lines.push('', args.summary.trim());
|
|
629
|
+
if (args.blocked_reason) {
|
|
630
|
+
lines.push('', 'No implementation, decision, question, reservation, or task mutation is authorized by this receipt.');
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
lines.push('', '## Read-only subagent reports');
|
|
634
|
+
const reports = [...args.reports].sort((left, right) => left.agent.localeCompare(right.agent) || left.focus.localeCompare(right.focus));
|
|
635
|
+
for (const report of reports) {
|
|
636
|
+
lines.push('', `### ${report.agent.trim()} — ${report.focus.trim()}`, '', `Repository scopes: ${report.repository_scope_ids.join(', ')}`, `Inspected paths: ${report.inspected_paths.join(', ') || 'none available'}`, '', '**Findings**', ...report.findings.map((finding) => `- ${finding.trim()}`), '', `Likely impact: ${report.likely_impact.trim()}`, '', '**Unresolved questions**', ...(report.unresolved_questions.length > 0
|
|
637
|
+
? report.unresolved_questions.map((question) => `- ${question.trim()}`)
|
|
638
|
+
: ['- None reported']));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
lines.push('', '## Repository coverage');
|
|
642
|
+
for (const coverage of orderedCoverage) {
|
|
643
|
+
lines.push(`- ${coverage.repository_scope_id}: ${coverage.disposition} — ${coverage.reason.trim()}`);
|
|
644
|
+
}
|
|
645
|
+
return lines.join('\n');
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Persists proof that read-only exploration happened before any decision or
|
|
649
|
+
* mutation. The receipt is deliberately session-local while its evidence is a
|
|
650
|
+
* durable analysis artifact tied to the exact task, run, and topology revision.
|
|
651
|
+
*/
|
|
652
|
+
export async function recordContextExplorationHandler(deps, attribution, session, args) {
|
|
653
|
+
const active = joinedRunOrError(session, 'record_context_exploration');
|
|
654
|
+
if ('content' in active)
|
|
655
|
+
return active;
|
|
656
|
+
if (args.task_id !== active.taskId) {
|
|
657
|
+
return errorResult(`record_context_exploration must stay on this agent run's task (${active.taskId}).`);
|
|
658
|
+
}
|
|
659
|
+
if (!Number.isInteger(args.topology_revision) || args.topology_revision !== active.topologyRevision) {
|
|
660
|
+
return errorResult(`record_context_exploration requires this run's topology revision (${active.topologyRevision}).`);
|
|
661
|
+
}
|
|
662
|
+
const blocked = args.blocked_reason === 'subagents_unavailable';
|
|
663
|
+
if (blocked && args.reports.length > 0) {
|
|
664
|
+
return errorResult('A subagents_unavailable receipt must not claim any read-only subagent reports.');
|
|
665
|
+
}
|
|
666
|
+
if (!blocked && (!Array.isArray(args.reports) || args.reports.length < 1)) {
|
|
667
|
+
return errorResult('record_context_exploration requires at least one structured read-only subagent report.');
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
const scopes = await activeRepositoryScopes(deps.client, active.projectId);
|
|
671
|
+
const scopeIds = scopes.map((scope) => scope.repository_scope_id);
|
|
672
|
+
if (scopeIds.length === 0) {
|
|
673
|
+
return errorResult('record_context_exploration found no active repository scopes for this project.');
|
|
674
|
+
}
|
|
675
|
+
if (!blocked) {
|
|
676
|
+
for (const report of args.reports) {
|
|
677
|
+
if (!report.agent?.trim() || !report.focus?.trim() || !report.likely_impact?.trim()) {
|
|
678
|
+
return errorResult('Every exploration report requires agent, focus, and likely_impact.');
|
|
679
|
+
}
|
|
680
|
+
if (!Array.isArray(report.repository_scope_ids) || report.repository_scope_ids.length === 0) {
|
|
681
|
+
return errorResult('Every exploration report must be assigned at least one repository scope.');
|
|
682
|
+
}
|
|
683
|
+
if (!Array.isArray(report.inspected_paths) || !Array.isArray(report.findings) || report.findings.length === 0) {
|
|
684
|
+
return errorResult('Every exploration report requires inspected_paths and at least one finding.');
|
|
685
|
+
}
|
|
686
|
+
if (!Array.isArray(report.unresolved_questions)) {
|
|
687
|
+
return errorResult('Every exploration report requires an unresolved_questions array (which may be empty).');
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const reportScopeError = exactScopeSet(scopeIds, args.reports.flatMap((report) => report.repository_scope_ids), 'Exploration reports');
|
|
691
|
+
if (reportScopeError)
|
|
692
|
+
return errorResult(reportScopeError);
|
|
693
|
+
}
|
|
694
|
+
if (!Array.isArray(args.coverage)) {
|
|
695
|
+
return errorResult('record_context_exploration requires repository coverage.');
|
|
696
|
+
}
|
|
697
|
+
const coverageError = exactScopeSet(scopeIds, args.coverage.map((coverage) => coverage.repository_scope_id), 'Repository coverage');
|
|
698
|
+
if (coverageError)
|
|
699
|
+
return errorResult(coverageError);
|
|
700
|
+
const coverageByScope = new Map(args.coverage.map((coverage) => [coverage.repository_scope_id, coverage]));
|
|
701
|
+
const orderedCoverage = scopeIds.map((scopeId) => coverageByScope.get(scopeId));
|
|
702
|
+
for (const coverage of orderedCoverage) {
|
|
703
|
+
if (!coverage.reason?.trim()) {
|
|
704
|
+
return errorResult('Every exploration coverage entry requires a non-empty reason.');
|
|
705
|
+
}
|
|
706
|
+
const unavailable = coverage.disposition === 'unavailable_acknowledged';
|
|
707
|
+
if (unavailable !== Boolean(coverage.acknowledgement_id)) {
|
|
708
|
+
return errorResult('Exploration coverage acknowledgement_id is required only for unavailable_acknowledged repositories.');
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const artifactResult = await createArtifactHandler(bindProject(deps, active.projectId), attribution, {
|
|
712
|
+
task_id: active.taskId,
|
|
713
|
+
type: 'analysis',
|
|
714
|
+
format: 'md',
|
|
715
|
+
content: explorationArtifactContent(args, orderedCoverage),
|
|
716
|
+
coverage: orderedCoverage,
|
|
717
|
+
}, active);
|
|
718
|
+
if (artifactResult.isError)
|
|
719
|
+
return artifactResult;
|
|
720
|
+
const first = artifactResult.content[0];
|
|
721
|
+
if (!first || first.type !== 'text')
|
|
722
|
+
throw new Error('analysis artifact returned no text result');
|
|
723
|
+
const payload = JSON.parse(first.text);
|
|
724
|
+
const artifactId = typeof payload.artifact?.id === 'string' ? payload.artifact.id : null;
|
|
725
|
+
if (!artifactId)
|
|
726
|
+
throw new Error('analysis artifact returned no id');
|
|
727
|
+
session.exploration = {
|
|
728
|
+
artifactId,
|
|
729
|
+
taskId: active.taskId,
|
|
730
|
+
projectId: active.projectId,
|
|
731
|
+
agentRunId: active.runId,
|
|
732
|
+
topologyRevision: active.topologyRevision,
|
|
733
|
+
repositoryScopeIds: scopeIds,
|
|
734
|
+
status: blocked ? 'blocked' : 'explored',
|
|
735
|
+
};
|
|
736
|
+
return textResult({
|
|
737
|
+
status: blocked ? 'blocked' : 'explored',
|
|
738
|
+
artifact_id: artifactId,
|
|
739
|
+
repository_coverage: orderedCoverage,
|
|
740
|
+
reports_recorded: args.reports.length,
|
|
741
|
+
instruction: blocked
|
|
742
|
+
? 'Read-only subagents are unavailable. Do not make decisions, ask questions, reserve paths, or mutate work. End with a blocked or aborted persistence receipt.'
|
|
743
|
+
: 'Context exploration is persisted. Decision, question, reservation, and mutation tools are now available for this session.',
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
return coordinationError('record_context_exploration', err);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
function isRealQuestion(text) {
|
|
751
|
+
const trimmed = text?.trim() ?? '';
|
|
752
|
+
return trimmed.length >= 4 && /[\p{L}\p{N}]/u.test(trimmed) && trimmed.includes('?');
|
|
753
|
+
}
|
|
754
|
+
function isPersistedAgentQuestion(row, taskId, agentRunId) {
|
|
755
|
+
if (row.task_id !== taskId || row.agent_run_id !== agentRunId || typeof row.body !== 'string')
|
|
756
|
+
return false;
|
|
757
|
+
const match = row.body.match(/^\[question:([a-z_]+)]\s+([\s\S]+)$/);
|
|
758
|
+
return Boolean(match &&
|
|
759
|
+
QUESTION_CATEGORIES.includes(match[1]) &&
|
|
760
|
+
isRealQuestion(match[2]));
|
|
761
|
+
}
|
|
762
|
+
export async function askQuestionHandler(deps, attribution, args, agentRun) {
|
|
763
|
+
if (args.task_id !== agentRun.taskId) {
|
|
764
|
+
return errorResult(`ask_question must stay on this agent run's task (${agentRun.taskId}).`);
|
|
765
|
+
}
|
|
766
|
+
if (!QUESTION_CATEGORIES.includes(args.category))
|
|
767
|
+
return errorResult('ask_question requires a supported category.');
|
|
768
|
+
if (!isRealQuestion(args.question)) {
|
|
769
|
+
return errorResult('ask_question requires real question text containing a question mark.');
|
|
770
|
+
}
|
|
771
|
+
const question = args.question.trim();
|
|
772
|
+
const result = await addCommentHandler(bindProject(deps, agentRun.projectId), attribution, { task_id: agentRun.taskId, body: `[question:${args.category}] ${question}` }, agentRun.runId);
|
|
773
|
+
if (result.isError)
|
|
774
|
+
return result;
|
|
775
|
+
const first = result.content[0];
|
|
776
|
+
if (!first || first.type !== 'text')
|
|
777
|
+
return errorResult('ask_question returned no persisted comment.');
|
|
778
|
+
const payload = JSON.parse(first.text);
|
|
779
|
+
return textResult({
|
|
780
|
+
question: { category: args.category, text: question, comment_id: payload.comment?.id ?? null },
|
|
781
|
+
comment: payload.comment ?? null,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
export async function endAgentRunHandler(deps, session, args = {}, options = {}) {
|
|
785
|
+
const active = runOrError(session, 'end_work');
|
|
786
|
+
if ('content' in active)
|
|
787
|
+
return active;
|
|
788
|
+
try {
|
|
789
|
+
if (options.requirePersistenceReceipt) {
|
|
790
|
+
const exploration = session.exploration;
|
|
791
|
+
const receipt = args.persistence_receipt;
|
|
792
|
+
if (!exploration || !receipt) {
|
|
793
|
+
return errorResult('end_work requires a persistence_receipt and this session\'s persisted context exploration artifact.');
|
|
794
|
+
}
|
|
795
|
+
const outcome = receipt.outcome ?? 'completed';
|
|
796
|
+
if (exploration.status === 'blocked' && !['blocked', 'aborted'].includes(outcome)) {
|
|
797
|
+
return errorResult('A subagents_unavailable session may end only with outcome blocked or aborted.');
|
|
798
|
+
}
|
|
799
|
+
if (exploration.status === 'explored' && !['completed', 'blocked'].includes(outcome)) {
|
|
800
|
+
return errorResult('An explored session may end as completed, or blocked after persisting a question for the user.');
|
|
801
|
+
}
|
|
802
|
+
if (exploration.status === 'explored' && outcome === 'blocked') {
|
|
803
|
+
const questionRows = (await must(deps.client
|
|
804
|
+
.from('comments')
|
|
805
|
+
.select('id,task_id,agent_run_id,body,created_at')
|
|
806
|
+
.eq('task_id', active.taskId)
|
|
807
|
+
.eq('agent_run_id', active.runId)
|
|
808
|
+
.order('created_at', { ascending: true })
|
|
809
|
+
.order('id', { ascending: true }))) ?? [];
|
|
810
|
+
if (!questionRows.some((row) => isPersistedAgentQuestion(row, active.taskId, active.runId))) {
|
|
811
|
+
return errorResult('An explored session may end as blocked only after ask_question persists a real categorized question for this run.');
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (!Number.isInteger(receipt.final_task_revision) || receipt.final_task_revision < 1) {
|
|
815
|
+
return errorResult('end_work persistence_receipt requires a positive final_task_revision.');
|
|
816
|
+
}
|
|
817
|
+
if (!Array.isArray(receipt.artifact_ids) || receipt.artifact_ids.length === 0) {
|
|
818
|
+
return errorResult('end_work persistence_receipt requires persisted artifact ids.');
|
|
819
|
+
}
|
|
820
|
+
const artifactIds = [...new Set(receipt.artifact_ids)];
|
|
821
|
+
if (artifactIds.length !== receipt.artifact_ids.length) {
|
|
822
|
+
return errorResult('end_work persistence_receipt artifact_ids must be unique.');
|
|
823
|
+
}
|
|
824
|
+
if (!artifactIds.includes(exploration.artifactId)) {
|
|
825
|
+
return errorResult('end_work persistence_receipt must include the context exploration artifact id.');
|
|
826
|
+
}
|
|
827
|
+
const task = await must(deps.client
|
|
828
|
+
.from('tasks')
|
|
829
|
+
.select('id,revision')
|
|
830
|
+
.eq('id', active.taskId)
|
|
831
|
+
.eq('project_id', active.projectId)
|
|
832
|
+
.maybeSingle());
|
|
833
|
+
if (!task || Number(task.revision) !== receipt.final_task_revision) {
|
|
834
|
+
return errorResult('end_work final_task_revision is stale. Fetch the task and persist the latest revision first.');
|
|
835
|
+
}
|
|
836
|
+
const artifactRows = (await must(deps.client
|
|
837
|
+
.from('artifacts')
|
|
838
|
+
.select('id,task_id,agent_run_id,deleted_at')
|
|
839
|
+
.eq('task_id', active.taskId)
|
|
840
|
+
.eq('agent_run_id', active.runId)
|
|
841
|
+
.is('deleted_at', null)
|
|
842
|
+
.order('created_at', { ascending: true })
|
|
843
|
+
.order('id', { ascending: true }))) ?? [];
|
|
844
|
+
const artifactsById = new Map(artifactRows.map((artifact) => [artifact.id, artifact]));
|
|
845
|
+
const persistedArtifactIds = artifactRows.map((artifact) => artifact.id);
|
|
846
|
+
const missingArtifactIds = persistedArtifactIds.filter((id) => !artifactIds.includes(id));
|
|
847
|
+
const foreignArtifactIds = artifactIds.filter((id) => !artifactsById.has(id));
|
|
848
|
+
if (missingArtifactIds.length > 0 || foreignArtifactIds.length > 0) {
|
|
849
|
+
return errorResult('end_work persistence_receipt artifact_ids must exactly match every non-deleted artifact persisted by ' +
|
|
850
|
+
`this task run. Missing: ${missingArtifactIds.join(', ') || 'none'}. ` +
|
|
851
|
+
`Foreign or deleted: ${foreignArtifactIds.join(', ') || 'none'}.`);
|
|
852
|
+
}
|
|
853
|
+
const explorationArtifact = artifactsById.get(exploration.artifactId);
|
|
854
|
+
if (!explorationArtifact || explorationArtifact.task_id !== exploration.taskId ||
|
|
855
|
+
explorationArtifact.agent_run_id !== exploration.agentRunId) {
|
|
856
|
+
return errorResult('end_work could not verify this session\'s persisted context exploration artifact.');
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const ended = await must(deps.client.rpc('end_agent_run', {
|
|
860
|
+
p_run: active.runId,
|
|
861
|
+
p_reason: args.reason?.trim() || 'completed',
|
|
862
|
+
}));
|
|
863
|
+
session.run = null;
|
|
864
|
+
session.exploration = null;
|
|
865
|
+
return textResult({
|
|
866
|
+
ended: Boolean(ended),
|
|
867
|
+
agent_run_id: active.runId,
|
|
868
|
+
...(args.persistence_receipt ? { persistence_receipt: args.persistence_receipt } : {}),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
catch (err) {
|
|
872
|
+
return coordinationError('end_work', err);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
// Handlers — plain async functions, unit-testable without a transport.
|
|
877
|
+
// ---------------------------------------------------------------------------
|
|
878
|
+
export async function listTasksHandler(deps, args = {}) {
|
|
879
|
+
try {
|
|
880
|
+
const projects = await accessibleProjects(deps);
|
|
881
|
+
if (args.project_id && !projects.some((project) => project.id === args.project_id)) {
|
|
882
|
+
return errorResult(`Project "${args.project_id}" is not initialized on this machine.`);
|
|
883
|
+
}
|
|
884
|
+
const projectIds = args.project_id ? [args.project_id] : projects.map((project) => project.id);
|
|
885
|
+
if (projectIds.length === 0)
|
|
886
|
+
return textResult({ projects: [], tasks: [] });
|
|
887
|
+
let query = deps.client.from('tasks').select(TASK_SELECT);
|
|
888
|
+
query = projectIds.length === 1
|
|
889
|
+
? query.eq('project_id', projectIds[0])
|
|
890
|
+
: query.in('project_id', projectIds);
|
|
891
|
+
const rows = (await must(query
|
|
892
|
+
.order('status', { ascending: true })
|
|
893
|
+
.order('position', { ascending: true })
|
|
894
|
+
.order('created_at', { ascending: true })
|
|
895
|
+
.order('id', { ascending: true }))) ?? [];
|
|
896
|
+
const tasks = rows.map(parseTaskRow).map((t, index) => ({
|
|
897
|
+
id: t.id,
|
|
898
|
+
project_id: rows[index]?.project_id,
|
|
899
|
+
project: projects.find((project) => project.id === rows[index]?.project_id) ?? { id: rows[index]?.project_id },
|
|
900
|
+
name: t.name,
|
|
901
|
+
status: t.status,
|
|
902
|
+
owner: t.owner,
|
|
903
|
+
due_date: t.due_date,
|
|
904
|
+
tags: t.tags,
|
|
905
|
+
unmet_dependency_count: t.unmet_dependencies.length,
|
|
906
|
+
}));
|
|
907
|
+
return textResult({ projects, tasks });
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
return errorResult(`list_tasks failed: ${err.message}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
export async function getTaskHandler(deps, _attribution, args, machineId) {
|
|
914
|
+
try {
|
|
915
|
+
const { client } = deps;
|
|
916
|
+
const projects = await accessibleProjects(deps, machineId);
|
|
917
|
+
const allowedProjectIds = new Set(projects.map((project) => project.id));
|
|
918
|
+
let highlightArtifactId = null;
|
|
919
|
+
let row = deps.projectId
|
|
920
|
+
? await must(client.from('tasks').select(TASK_SELECT).eq('id', args.id).eq('project_id', deps.projectId).maybeSingle())
|
|
921
|
+
: await must(client.from('tasks').select(TASK_SELECT).eq('id', args.id).maybeSingle());
|
|
922
|
+
if (!row) {
|
|
923
|
+
// spec: get_task also accepts an artifact id (the `/ctrl-spc artifact
|
|
924
|
+
// <id>` path) — resolve to its parent task, highlighted first.
|
|
925
|
+
const artifact = await must(client.from('artifacts').select('id,task_id').eq('id', args.id).maybeSingle());
|
|
926
|
+
if (!artifact)
|
|
927
|
+
return errorResult(`No task or artifact found for id "${args.id}".`);
|
|
928
|
+
highlightArtifactId = artifact.id;
|
|
929
|
+
row = deps.projectId
|
|
930
|
+
? await must(client.from('tasks').select(TASK_SELECT).eq('id', artifact.task_id).eq('project_id', deps.projectId).maybeSingle())
|
|
931
|
+
: await must(client.from('tasks').select(TASK_SELECT).eq('id', artifact.task_id).maybeSingle());
|
|
932
|
+
if (!row)
|
|
933
|
+
return errorResult(`No task or artifact found for id "${args.id}".`);
|
|
934
|
+
}
|
|
935
|
+
if (!allowedProjectIds.has(row.project_id)) {
|
|
936
|
+
return errorResult(`The resolved item belongs to project "${row.project_id}", which is not initialized on this machine.`);
|
|
937
|
+
}
|
|
938
|
+
const task = parseTaskRow(row);
|
|
939
|
+
const topologyMachineId = machineId ?? deps.machineId;
|
|
940
|
+
const [rawComments, rawArtifacts, rawVersions, rawAgentRuns, rawTaskClaims, collaborationDocument, topology] = await Promise.all([
|
|
941
|
+
must(client
|
|
942
|
+
.from('comments')
|
|
943
|
+
.select(COMMENT_SELECT)
|
|
944
|
+
.eq('task_id', row.id)
|
|
945
|
+
.order('created_at', { ascending: true })
|
|
946
|
+
.order('id', { ascending: true })),
|
|
947
|
+
must(client
|
|
948
|
+
.from('artifacts')
|
|
949
|
+
.select(ARTIFACT_SELECT)
|
|
950
|
+
.eq('task_id', row.id)
|
|
951
|
+
.is('deleted_at', null)
|
|
952
|
+
.order('created_at', { ascending: true })
|
|
953
|
+
.order('id', { ascending: true })),
|
|
954
|
+
must(client
|
|
955
|
+
.from('task_versions')
|
|
956
|
+
.select(TASK_VERSION_SELECT)
|
|
957
|
+
.eq('task_id', row.id)
|
|
958
|
+
.order('seq', { ascending: true })
|
|
959
|
+
.order('id', { ascending: true })),
|
|
960
|
+
must(client
|
|
961
|
+
.from('agent_runs')
|
|
962
|
+
.select(AGENT_RUN_SELECT)
|
|
963
|
+
.eq('task_id', row.id)
|
|
964
|
+
.in('state', ['preparing', 'active', 'waiting', 'ready'])
|
|
965
|
+
.is('ended_at', null)
|
|
966
|
+
.order('started_at', { ascending: true })
|
|
967
|
+
.order('id', { ascending: true })),
|
|
968
|
+
must(client
|
|
969
|
+
.from('task_claims')
|
|
970
|
+
.select(TASK_CLAIM_SELECT)
|
|
971
|
+
.eq('task_id', row.id)
|
|
972
|
+
.eq('state', 'active')
|
|
973
|
+
.is('released_at', null)
|
|
974
|
+
.order('created_at', { ascending: true })
|
|
975
|
+
.order('id', { ascending: true })),
|
|
976
|
+
must(client
|
|
977
|
+
.from('collaboration_documents')
|
|
978
|
+
.select(COLLABORATION_DOCUMENT_SELECT)
|
|
979
|
+
.eq('resource_type', 'task_description')
|
|
980
|
+
.eq('resource_id', row.id)
|
|
981
|
+
.eq('active', true)
|
|
982
|
+
.maybeSingle()),
|
|
983
|
+
topologyMachineId
|
|
984
|
+
? loadAgentProjectTopology(client, row.project_id, deps.userId, topologyMachineId)
|
|
985
|
+
: Promise.resolve(undefined),
|
|
986
|
+
]);
|
|
987
|
+
const comments = sortRecords((rawComments ?? []).map(normalizeComment), 'created_at', 'id');
|
|
988
|
+
const normalizedArtifacts = sortRecords((rawArtifacts ?? []).map(normalizeArtifact), 'created_at', 'id');
|
|
989
|
+
const artifacts = highlightArtifactId
|
|
990
|
+
? [
|
|
991
|
+
...normalizedArtifacts.filter((artifact) => artifact.id === highlightArtifactId),
|
|
992
|
+
...normalizedArtifacts.filter((artifact) => artifact.id !== highlightArtifactId),
|
|
993
|
+
]
|
|
994
|
+
: normalizedArtifacts;
|
|
995
|
+
const taskVersions = sortRecords(rawVersions ?? [], 'seq', 'id');
|
|
996
|
+
const now = Date.now();
|
|
997
|
+
const leaseIsLive = (value) => typeof value === 'string' && Number.isFinite(new Date(value).getTime()) && new Date(value).getTime() > now;
|
|
998
|
+
const activeAgentRuns = sortRecords((rawAgentRuns ?? []).filter((run) => leaseIsLive(run.lease_expires_at)), 'started_at', 'id');
|
|
999
|
+
const activeRunIds = new Set(activeAgentRuns.map((run) => String(run.id)));
|
|
1000
|
+
const activeTaskClaims = sortRecords((rawTaskClaims ?? []).filter((claim) => activeRunIds.has(String(claim.agent_run_id)) && leaseIsLive(claim.expires_at)), 'created_at', 'fencing_token', 'id');
|
|
1001
|
+
const claimIds = activeTaskClaims.map((claim) => String(claim.id));
|
|
1002
|
+
const rawReservations = claimIds.length > 0
|
|
1003
|
+
? (await must(client
|
|
1004
|
+
.from('work_reservations')
|
|
1005
|
+
.select(WORK_RESERVATION_SELECT)
|
|
1006
|
+
.in('task_claim_id', claimIds)
|
|
1007
|
+
.is('released_at', null)
|
|
1008
|
+
.order('created_at', { ascending: true })
|
|
1009
|
+
.order('id', { ascending: true }))) ?? []
|
|
1010
|
+
: [];
|
|
1011
|
+
const activeWorkReservations = sortRecords(rawReservations.filter((reservation) => leaseIsLive(reservation.expires_at)), 'created_at', 'fencing_token', 'id');
|
|
1012
|
+
const collaborationPlainText = typeof collaborationDocument?.plain_text === 'string'
|
|
1013
|
+
? collaborationDocument.plain_text
|
|
1014
|
+
: row.description;
|
|
1015
|
+
const collaborativeDescription = collaborationDocument
|
|
1016
|
+
? { ...collaborationDocument, plain_text: collaborationPlainText }
|
|
1017
|
+
: {
|
|
1018
|
+
active: false,
|
|
1019
|
+
resource_type: 'task_description',
|
|
1020
|
+
resource_id: row.id,
|
|
1021
|
+
plain_text: row.description,
|
|
1022
|
+
source_revision: row.revision,
|
|
1023
|
+
};
|
|
1024
|
+
const project = projects.find((candidate) => candidate.id === row.project_id) ?? { id: row.project_id };
|
|
1025
|
+
return textResult({
|
|
1026
|
+
project,
|
|
1027
|
+
task: {
|
|
1028
|
+
...task,
|
|
1029
|
+
description: collaborationPlainText,
|
|
1030
|
+
persisted_description: row.description,
|
|
1031
|
+
},
|
|
1032
|
+
unmet_dependencies: task.unmet_dependencies,
|
|
1033
|
+
predecessor_edges: task.predecessor_edges,
|
|
1034
|
+
dependent_edges: task.dependent_edges,
|
|
1035
|
+
comments,
|
|
1036
|
+
artifacts,
|
|
1037
|
+
task_versions: taskVersions,
|
|
1038
|
+
active_agent_runs: activeAgentRuns,
|
|
1039
|
+
active_task_claims: activeTaskClaims,
|
|
1040
|
+
active_work_reservations: activeWorkReservations,
|
|
1041
|
+
collaborative_description: collaborativeDescription,
|
|
1042
|
+
...(topology ? { topology } : {}),
|
|
1043
|
+
highlighted_artifact_id: highlightArtifactId,
|
|
1044
|
+
protocol: PROTOCOL_SHORT,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
catch (err) {
|
|
1048
|
+
return errorResult(`get_task failed: ${err.message}`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
export async function createTaskHandler(deps, _attribution, args) {
|
|
1052
|
+
try {
|
|
1053
|
+
if (!args.name || !args.name.trim())
|
|
1054
|
+
return errorResult('create_task requires a non-empty name.');
|
|
1055
|
+
const { client, userId } = deps;
|
|
1056
|
+
const projectId = requiredProjectId(deps, 'create_task');
|
|
1057
|
+
const tags = await resolveOrCreateTags(client, projectId, args.tags ?? []);
|
|
1058
|
+
const inserted = await must(client
|
|
1059
|
+
.from('tasks')
|
|
1060
|
+
.insert({
|
|
1061
|
+
project_id: projectId,
|
|
1062
|
+
name: args.name,
|
|
1063
|
+
description: args.description ?? '',
|
|
1064
|
+
owner_id: userId,
|
|
1065
|
+
status: 'backlog',
|
|
1066
|
+
})
|
|
1067
|
+
.select('id')
|
|
1068
|
+
.single());
|
|
1069
|
+
if (!inserted)
|
|
1070
|
+
throw new Error('Task insert returned no row.');
|
|
1071
|
+
const taskId = inserted.id;
|
|
1072
|
+
if (tags.length > 0) {
|
|
1073
|
+
await must(client.from('task_tags').insert(tags.map((t) => ({ task_id: taskId, tag_id: t.id }))));
|
|
1074
|
+
}
|
|
1075
|
+
if (args.depends_on && args.depends_on.length > 0) {
|
|
1076
|
+
await must(client.from('task_dependencies').insert(args.depends_on.map((depId) => ({ task_id: taskId, depends_on_task_id: depId }))));
|
|
1077
|
+
}
|
|
1078
|
+
if (args.before_task_id) {
|
|
1079
|
+
await must(client.rpc('move_task', { p_task_id: taskId, p_status: 'backlog', p_before_task_id: args.before_task_id }));
|
|
1080
|
+
}
|
|
1081
|
+
const fresh = await must(client.from('tasks').select(TASK_SELECT).eq('id', taskId).maybeSingle());
|
|
1082
|
+
if (!fresh)
|
|
1083
|
+
throw new Error('Created task could not be re-fetched.');
|
|
1084
|
+
return textResult({ task: parseTaskRow(fresh) });
|
|
1085
|
+
}
|
|
1086
|
+
catch (err) {
|
|
1087
|
+
return errorResult(`create_task failed: ${err.message}`);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
export async function updateTaskHandler(deps, _attribution, args, agentRun) {
|
|
1091
|
+
try {
|
|
1092
|
+
const { client } = deps;
|
|
1093
|
+
const projectId = requiredProjectId(deps, 'update_task');
|
|
1094
|
+
const { id, fields, expected_revision } = args;
|
|
1095
|
+
if (agentRun && (id !== agentRun.taskId || projectId !== agentRun.projectId)) {
|
|
1096
|
+
return errorResult(`update_task must stay on this agent run's task ${agentRun.taskId} in project ${agentRun.projectId}.`);
|
|
1097
|
+
}
|
|
1098
|
+
if (!Number.isInteger(expected_revision) || expected_revision < 1) {
|
|
1099
|
+
return errorResult('update_task requires expected_revision from the most recent get_task response.');
|
|
1100
|
+
}
|
|
1101
|
+
// A blank/whitespace-only sprint name would silently create a garbage
|
|
1102
|
+
// sprint row via resolveOrCreateSprint's insert-if-missing path. Reject
|
|
1103
|
+
// it up front — before any query — leaving `null` (clear assignment) as
|
|
1104
|
+
// the only valid way to unset a sprint.
|
|
1105
|
+
if (fields.sprint !== undefined && fields.sprint !== null && fields.sprint.trim() === '') {
|
|
1106
|
+
return errorResult('update_task: sprint name cannot be empty or whitespace-only.');
|
|
1107
|
+
}
|
|
1108
|
+
const current = await must(client.from('tasks').select('id,status,revision').eq('id', id).eq('project_id', projectId).maybeSingle());
|
|
1109
|
+
if (!current)
|
|
1110
|
+
return errorResult(`No task found for id "${id}" in this project.`);
|
|
1111
|
+
const patch = {};
|
|
1112
|
+
if (fields.name !== undefined)
|
|
1113
|
+
patch.name = fields.name;
|
|
1114
|
+
if (fields.description !== undefined)
|
|
1115
|
+
patch.description = fields.description;
|
|
1116
|
+
if (fields.due_date !== undefined)
|
|
1117
|
+
patch.due_date = fields.due_date;
|
|
1118
|
+
if (fields.owner !== undefined)
|
|
1119
|
+
patch.owner_id = await resolveOwnerId(client, fields.owner);
|
|
1120
|
+
if (fields.sprint !== undefined) {
|
|
1121
|
+
patch.sprint_id = fields.sprint === null ? null : await resolveOrCreateSprint(client, projectId, fields.sprint);
|
|
1122
|
+
}
|
|
1123
|
+
if (fields.status !== undefined)
|
|
1124
|
+
patch.status = fields.status;
|
|
1125
|
+
// A status change implies append-to-destination; an explicit
|
|
1126
|
+
// before_task_id reorders within the current/destination column. A
|
|
1127
|
+
// same-value status without before_task_id preserves board position.
|
|
1128
|
+
const statusChanged = fields.status !== undefined && fields.status !== current.status;
|
|
1129
|
+
let tagIds = null;
|
|
1130
|
+
if (fields.tags !== undefined) {
|
|
1131
|
+
const tags = await resolveOrCreateTags(client, projectId, fields.tags);
|
|
1132
|
+
tagIds = tags.map((tag) => tag.id);
|
|
1133
|
+
}
|
|
1134
|
+
await must(client.rpc(agentRun ? 'save_agent_task_if_current' : 'save_task_if_current', agentRun ? {
|
|
1135
|
+
p_run: agentRun.runId,
|
|
1136
|
+
p_claim_fencing_token: agentRun.fencingToken,
|
|
1137
|
+
p_expected_revision: expected_revision,
|
|
1138
|
+
p_changes: patch,
|
|
1139
|
+
p_tag_ids: tagIds,
|
|
1140
|
+
p_reorder: statusChanged || fields.before_task_id !== undefined,
|
|
1141
|
+
p_before_task_id: fields.before_task_id ?? null,
|
|
1142
|
+
} : {
|
|
1143
|
+
p_task_id: id,
|
|
1144
|
+
p_expected_revision: expected_revision,
|
|
1145
|
+
p_changes: patch,
|
|
1146
|
+
p_tag_ids: tagIds,
|
|
1147
|
+
p_reorder: statusChanged || fields.before_task_id !== undefined,
|
|
1148
|
+
p_before_task_id: fields.before_task_id ?? null,
|
|
1149
|
+
}));
|
|
1150
|
+
const fresh = await must(client.from('tasks').select(TASK_SELECT).eq('id', id).maybeSingle());
|
|
1151
|
+
if (!fresh)
|
|
1152
|
+
throw new Error('Updated task could not be re-fetched.');
|
|
1153
|
+
return textResult({ task: parseTaskRow(fresh) });
|
|
1154
|
+
}
|
|
1155
|
+
catch (err) {
|
|
1156
|
+
if (err.message === 'edit_conflict') {
|
|
1157
|
+
return errorResult('update_task conflict: this work item changed after it was read. Call get_task again, review the latest revision, and retry intentionally.');
|
|
1158
|
+
}
|
|
1159
|
+
if (agentRun)
|
|
1160
|
+
return coordinationError('update_task', err);
|
|
1161
|
+
return errorResult(`update_task failed: ${err.message}`);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
export async function createArtifactHandler(deps, attribution, args, agentRun) {
|
|
1165
|
+
try {
|
|
1166
|
+
if (!args.task_id)
|
|
1167
|
+
return errorResult('create_artifact requires task_id.');
|
|
1168
|
+
if (!args.content)
|
|
1169
|
+
return errorResult('create_artifact requires content.');
|
|
1170
|
+
if (agentRun) {
|
|
1171
|
+
if (agentRun.state !== 'joined') {
|
|
1172
|
+
return errorResult('create_artifact is blocked while repositories are unavailable. Fetch or acknowledge them, then call begin_work again.');
|
|
1173
|
+
}
|
|
1174
|
+
if (args.task_id !== agentRun.taskId) {
|
|
1175
|
+
return errorResult(`create_artifact task_id must match this agent run's task (${agentRun.taskId}).`);
|
|
1176
|
+
}
|
|
1177
|
+
if (!Array.isArray(args.coverage) || args.coverage.length === 0) {
|
|
1178
|
+
return errorResult('create_artifact requires repository coverage for every active project scope.');
|
|
1179
|
+
}
|
|
1180
|
+
for (const coverage of args.coverage) {
|
|
1181
|
+
if (!coverage.repository_scope_id || !coverage.reason?.trim()) {
|
|
1182
|
+
return errorResult('Every create_artifact coverage entry requires repository_scope_id and a non-empty reason.');
|
|
1183
|
+
}
|
|
1184
|
+
const unavailable = coverage.disposition === 'unavailable_acknowledged';
|
|
1185
|
+
if (unavailable !== Boolean(coverage.acknowledgement_id)) {
|
|
1186
|
+
return errorResult('create_artifact coverage acknowledgement_id is required only for unavailable_acknowledged repositories.');
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const artifact = await must(deps.client.rpc('create_agent_artifact', {
|
|
1190
|
+
p_run: agentRun.runId,
|
|
1191
|
+
p_claim_fencing_token: agentRun.fencingToken,
|
|
1192
|
+
p_topology_revision: agentRun.topologyRevision,
|
|
1193
|
+
p_type: args.type,
|
|
1194
|
+
p_format: args.format ?? 'md',
|
|
1195
|
+
p_content: args.content,
|
|
1196
|
+
p_storage_path: null,
|
|
1197
|
+
p_coverage: args.coverage.map((coverage) => ({
|
|
1198
|
+
repository_scope_id: coverage.repository_scope_id,
|
|
1199
|
+
disposition: coverage.disposition,
|
|
1200
|
+
reason: coverage.reason.trim(),
|
|
1201
|
+
acknowledgement_id: coverage.acknowledgement_id ?? null,
|
|
1202
|
+
})),
|
|
1203
|
+
}));
|
|
1204
|
+
if (!artifact)
|
|
1205
|
+
throw new Error('create_agent_artifact returned no artifact');
|
|
1206
|
+
return textResult({ artifact, repository_coverage: args.coverage });
|
|
1207
|
+
}
|
|
1208
|
+
if (attribution) {
|
|
1209
|
+
return errorResult('Agent artifacts require a joined agent run and complete repository coverage.');
|
|
1210
|
+
}
|
|
1211
|
+
const task = await must(deps.client.from('tasks').select('id').eq('id', args.task_id)
|
|
1212
|
+
.eq('project_id', requiredProjectId(deps, 'create_artifact')).maybeSingle());
|
|
1213
|
+
if (!task)
|
|
1214
|
+
return errorResult(`No task found for id "${args.task_id}" in this project.`);
|
|
1215
|
+
const row = await must(deps.client
|
|
1216
|
+
.from('artifacts')
|
|
1217
|
+
.insert({
|
|
1218
|
+
task_id: args.task_id,
|
|
1219
|
+
type: args.type,
|
|
1220
|
+
format: args.format ?? 'md',
|
|
1221
|
+
content: args.content,
|
|
1222
|
+
created_by: deps.userId,
|
|
1223
|
+
from_agent: null,
|
|
1224
|
+
agent_run_id: null,
|
|
1225
|
+
})
|
|
1226
|
+
.select('id,task_id,type,format,content,storage_path,created_by,from_agent,created_at')
|
|
1227
|
+
.single());
|
|
1228
|
+
if (!row)
|
|
1229
|
+
throw new Error('Artifact insert returned no row.');
|
|
1230
|
+
return textResult({ artifact: row });
|
|
1231
|
+
}
|
|
1232
|
+
catch (err) {
|
|
1233
|
+
if (agentRun)
|
|
1234
|
+
return coordinationError('create_artifact', err);
|
|
1235
|
+
return errorResult(`create_artifact failed: ${err.message}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
export async function addCommentHandler(deps, attribution, args, agentRunId) {
|
|
1239
|
+
try {
|
|
1240
|
+
if (!args.task_id)
|
|
1241
|
+
return errorResult('add_comment requires task_id.');
|
|
1242
|
+
if (!args.body || !args.body.trim())
|
|
1243
|
+
return errorResult('add_comment requires a non-empty body.');
|
|
1244
|
+
const task = await must(deps.client.from('tasks').select('id').eq('id', args.task_id)
|
|
1245
|
+
.eq('project_id', requiredProjectId(deps, 'add_comment')).maybeSingle());
|
|
1246
|
+
if (!task)
|
|
1247
|
+
return errorResult(`No task found for id "${args.task_id}" in this project.`);
|
|
1248
|
+
const row = await must(deps.client
|
|
1249
|
+
.from('comments')
|
|
1250
|
+
.insert({
|
|
1251
|
+
task_id: args.task_id,
|
|
1252
|
+
author_id: deps.userId,
|
|
1253
|
+
body: args.body,
|
|
1254
|
+
from_agent: attribution,
|
|
1255
|
+
agent_run_id: agentRunId ?? null,
|
|
1256
|
+
})
|
|
1257
|
+
.select('id,task_id,body,author_id,from_agent,created_at,updated_at')
|
|
1258
|
+
.single());
|
|
1259
|
+
if (!row)
|
|
1260
|
+
throw new Error('Comment insert returned no row.');
|
|
1261
|
+
return textResult({ comment: row });
|
|
1262
|
+
}
|
|
1263
|
+
catch (err) {
|
|
1264
|
+
return errorResult(`add_comment failed: ${err.message}`);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
export function protocolPromptHandler() {
|
|
1268
|
+
return {
|
|
1269
|
+
description: 'CTRL+SPC §5a agent working protocol',
|
|
1270
|
+
messages: [{ role: 'user', content: { type: 'text', text: PROTOCOL_FULL } }],
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
// ---------------------------------------------------------------------------
|
|
1274
|
+
// Wiring — Streamable HTTP transport on a plain node:http server, session
|
|
1275
|
+
// mode (spec: clientInfo must be captured for attribution; in stateless mode
|
|
1276
|
+
// the initialize request doesn't durably attach to a Server instance across
|
|
1277
|
+
// requests, so this uses `sessionIdGenerator: () => randomUUID()` and one
|
|
1278
|
+
// `McpServer` per session — `server.server.getClientVersion()` is populated
|
|
1279
|
+
// synchronously during that session's `initialize` request and stays valid
|
|
1280
|
+
// for every later tool call on the same transport/session).
|
|
1281
|
+
// ---------------------------------------------------------------------------
|
|
1282
|
+
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
1283
|
+
const HEARTBEAT_INTERVAL_MS = 30 * 1000;
|
|
1284
|
+
export const BROKER_VERSION = '1.2.0';
|
|
1285
|
+
async function readJsonBody(req) {
|
|
1286
|
+
const chunks = [];
|
|
1287
|
+
for await (const chunk of req)
|
|
1288
|
+
chunks.push(chunk);
|
|
1289
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
1290
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
1291
|
+
}
|
|
1292
|
+
export async function startMcpServer(deps) {
|
|
1293
|
+
const port = deps.port ?? MCP_PORT;
|
|
1294
|
+
const machineId = deps.machineId ?? getMachineIdentity().id;
|
|
1295
|
+
const handlerDeps = {
|
|
1296
|
+
client: deps.client,
|
|
1297
|
+
...(deps.projectId ? { projectId: deps.projectId } : {}),
|
|
1298
|
+
machineId,
|
|
1299
|
+
userId: deps.userId,
|
|
1300
|
+
presence: deps.presence,
|
|
1301
|
+
};
|
|
1302
|
+
function resultMessage(result) {
|
|
1303
|
+
const first = result.content[0];
|
|
1304
|
+
return first?.type === 'text' ? first.text : 'unknown coordination error';
|
|
1305
|
+
}
|
|
1306
|
+
function stopRunTimers(session) {
|
|
1307
|
+
if (session.idleTimer)
|
|
1308
|
+
clearTimeout(session.idleTimer);
|
|
1309
|
+
if (session.heartbeatTimer)
|
|
1310
|
+
clearInterval(session.heartbeatTimer);
|
|
1311
|
+
session.idleTimer = undefined;
|
|
1312
|
+
session.heartbeatTimer = undefined;
|
|
1313
|
+
session.heartbeatFailures = 0;
|
|
1314
|
+
}
|
|
1315
|
+
async function endManagedSession(session, reason) {
|
|
1316
|
+
if (session.ending)
|
|
1317
|
+
return session.ending;
|
|
1318
|
+
session.ending = (async () => {
|
|
1319
|
+
stopRunTimers(session);
|
|
1320
|
+
if (!session.context.run)
|
|
1321
|
+
return;
|
|
1322
|
+
const result = await endAgentRunHandler(handlerDeps, session.context, { reason });
|
|
1323
|
+
if (result.isError) {
|
|
1324
|
+
// The durable lease still expires server-side. Drop local authority so
|
|
1325
|
+
// a disconnected/stale agent cannot keep mutating through this session.
|
|
1326
|
+
console.warn(`Warning: could not end agent run cleanly: ${resultMessage(result)}`);
|
|
1327
|
+
session.context.run = null;
|
|
1328
|
+
session.context.exploration = null;
|
|
1329
|
+
}
|
|
1330
|
+
})();
|
|
1331
|
+
try {
|
|
1332
|
+
await session.ending;
|
|
1333
|
+
}
|
|
1334
|
+
finally {
|
|
1335
|
+
session.ending = undefined;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
function resetSessionIdleTimer(session) {
|
|
1339
|
+
if (!session.context.run)
|
|
1340
|
+
return;
|
|
1341
|
+
if (session.idleTimer)
|
|
1342
|
+
clearTimeout(session.idleTimer);
|
|
1343
|
+
session.idleTimer = setTimeout(() => {
|
|
1344
|
+
void endManagedSession(session, 'MCP session idle timeout');
|
|
1345
|
+
}, IDLE_TIMEOUT_MS);
|
|
1346
|
+
session.idleTimer.unref();
|
|
1347
|
+
}
|
|
1348
|
+
function startRunTimers(session) {
|
|
1349
|
+
stopRunTimers(session);
|
|
1350
|
+
session.heartbeatFailures = 0;
|
|
1351
|
+
resetSessionIdleTimer(session);
|
|
1352
|
+
session.heartbeatTimer = setInterval(() => {
|
|
1353
|
+
if (!session.context.run)
|
|
1354
|
+
return;
|
|
1355
|
+
void heartbeatAgentRunHandler(handlerDeps, session.context).then((result) => {
|
|
1356
|
+
if (!result.isError) {
|
|
1357
|
+
session.heartbeatFailures = 0;
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
console.warn(`Warning: agent lease heartbeat failed: ${resultMessage(result)}`);
|
|
1361
|
+
session.heartbeatFailures = (session.heartbeatFailures ?? 0) + 1;
|
|
1362
|
+
const definitelyLost = /no longer owns an active lease/i.test(resultMessage(result));
|
|
1363
|
+
if (definitelyLost || session.heartbeatFailures >= 3) {
|
|
1364
|
+
stopRunTimers(session);
|
|
1365
|
+
session.context.run = null;
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
1369
|
+
session.heartbeatTimer.unref();
|
|
1370
|
+
}
|
|
1371
|
+
function requireActiveSessionRun(session, tool) {
|
|
1372
|
+
if (!session.context.run) {
|
|
1373
|
+
return errorResult(`${tool} requires begin_work in this MCP session before any mutation.`);
|
|
1374
|
+
}
|
|
1375
|
+
if (session.context.run.state !== 'joined') {
|
|
1376
|
+
return errorResult(`${tool} is blocked while required repositories are unavailable. ` +
|
|
1377
|
+
'Only heartbeat_work, acknowledge_unavailable_checkout, end_work, and begin_work are allowed for this waiting run.');
|
|
1378
|
+
}
|
|
1379
|
+
resetSessionIdleTimer(session);
|
|
1380
|
+
return null;
|
|
1381
|
+
}
|
|
1382
|
+
function requireExploredSessionRun(session, tool) {
|
|
1383
|
+
const missing = requireActiveSessionRun(session, tool);
|
|
1384
|
+
if (missing)
|
|
1385
|
+
return missing;
|
|
1386
|
+
const run = session.context.run;
|
|
1387
|
+
const exploration = session.context.exploration;
|
|
1388
|
+
if (!exploration || exploration.agentRunId !== run.runId ||
|
|
1389
|
+
exploration.taskId !== run.taskId || exploration.topologyRevision !== run.topologyRevision) {
|
|
1390
|
+
return errorResult(`${tool} is blocked until record_context_exploration persists complete read-only subagent reports ` +
|
|
1391
|
+
'and repository coverage for this task and topology revision.');
|
|
1392
|
+
}
|
|
1393
|
+
if (exploration.status !== 'explored') {
|
|
1394
|
+
return errorResult(`${tool} is blocked because read-only subagents were unavailable. ` +
|
|
1395
|
+
'Do not continue; end_work with a blocked or aborted persistence receipt.');
|
|
1396
|
+
}
|
|
1397
|
+
return null;
|
|
1398
|
+
}
|
|
1399
|
+
function buildServer(session) {
|
|
1400
|
+
const server = new McpServer({ name: 'ctrl-spc', version: BROKER_VERSION });
|
|
1401
|
+
function attribution() {
|
|
1402
|
+
return attributionFromClientName(server.server.getClientVersion()?.name);
|
|
1403
|
+
}
|
|
1404
|
+
function syncClientIdentity() {
|
|
1405
|
+
const info = server.server.getClientVersion();
|
|
1406
|
+
session.context.clientName = info?.name ?? '';
|
|
1407
|
+
session.context.clientVersion = info?.version ?? '';
|
|
1408
|
+
session.context.agentKind = attributionFromClientName(info?.name) ?? info?.name?.trim() ?? 'unknown';
|
|
1409
|
+
}
|
|
1410
|
+
server.registerTool('list_tasks', {
|
|
1411
|
+
description: 'List tasks across every project initialized on this machine, with project identity. Optionally filter to one project.',
|
|
1412
|
+
inputSchema: { project_id: z.string().optional() },
|
|
1413
|
+
}, async (args) => {
|
|
1414
|
+
resetSessionIdleTimer(session);
|
|
1415
|
+
return listTasksHandler(handlerDeps, args);
|
|
1416
|
+
});
|
|
1417
|
+
server.registerTool('get_task', {
|
|
1418
|
+
description: 'Read a full task — tags, sprint, comments, live artifacts, unmet dependencies, and compact protocol. Also accepts an artifact id. This read has no status, presence, claim, or reservation side effects; call begin_work explicitly before editing.',
|
|
1419
|
+
inputSchema: { id: z.string().describe('Task id, or an artifact id (resolves to its parent task)') },
|
|
1420
|
+
}, async ({ id }) => {
|
|
1421
|
+
resetSessionIdleTimer(session);
|
|
1422
|
+
return getTaskHandler(handlerDeps, attribution(), { id }, session.context.machineId);
|
|
1423
|
+
});
|
|
1424
|
+
server.registerTool('begin_work', {
|
|
1425
|
+
description: 'Explicitly begin work on a task. The first live agent coordinates; later agents join as collaborators. No agent may edit repository paths until reserve_work_paths succeeds.',
|
|
1426
|
+
inputSchema: { task_id: z.string() },
|
|
1427
|
+
}, async (args) => {
|
|
1428
|
+
syncClientIdentity();
|
|
1429
|
+
const result = await beginAgentRunHandler(handlerDeps, session.context, args);
|
|
1430
|
+
if (!result.isError && session.context.run)
|
|
1431
|
+
startRunTimers(session);
|
|
1432
|
+
return result;
|
|
1433
|
+
});
|
|
1434
|
+
server.registerTool('heartbeat_work', { description: 'Refresh this MCP session\'s agent-run lease. The broker also does this automatically.' }, async () => {
|
|
1435
|
+
resetSessionIdleTimer(session);
|
|
1436
|
+
return heartbeatAgentRunHandler(handlerDeps, session.context);
|
|
1437
|
+
});
|
|
1438
|
+
server.registerTool('reserve_work_paths', {
|
|
1439
|
+
description: 'Atomically reserve repository-relative paths before editing. Write reservations require the exact repository_checkout_id from get_task topology.checkouts, including when selecting an isolated worktree.',
|
|
1440
|
+
inputSchema: {
|
|
1441
|
+
topology_revision: z.number().int().positive(),
|
|
1442
|
+
reservations: z.array(z.object({
|
|
1443
|
+
repository_scope_id: z.string(),
|
|
1444
|
+
repository_checkout_id: z.string().optional().describe('Required for write mode; choose an exact id from get_task topology.checkouts'),
|
|
1445
|
+
path: z.string().optional().describe('Path relative to the selected repository scope; empty means the whole scope'),
|
|
1446
|
+
mode: z.enum(['read', 'write']).optional(),
|
|
1447
|
+
intent: z.string().optional(),
|
|
1448
|
+
})).min(1),
|
|
1449
|
+
},
|
|
1450
|
+
}, async (args) => {
|
|
1451
|
+
const missing = requireExploredSessionRun(session, 'reserve_work_paths');
|
|
1452
|
+
if (missing)
|
|
1453
|
+
return missing;
|
|
1454
|
+
return reserveWorkPathsHandler(handlerDeps, session.context, args);
|
|
1455
|
+
});
|
|
1456
|
+
server.registerTool('record_context_exploration', {
|
|
1457
|
+
description: 'Persist structured read-only subagent exploration for every active repository scope before any decision, question, reservation, or mutation.',
|
|
1458
|
+
inputSchema: {
|
|
1459
|
+
task_id: z.string(),
|
|
1460
|
+
topology_revision: z.number().int().positive(),
|
|
1461
|
+
reports: z.array(z.object({
|
|
1462
|
+
agent: z.string(),
|
|
1463
|
+
focus: z.string(),
|
|
1464
|
+
repository_scope_ids: z.array(z.string()).min(1),
|
|
1465
|
+
inspected_paths: z.array(z.string()),
|
|
1466
|
+
findings: z.array(z.string()).min(1),
|
|
1467
|
+
likely_impact: z.string(),
|
|
1468
|
+
unresolved_questions: z.array(z.string()),
|
|
1469
|
+
})),
|
|
1470
|
+
coverage: z.array(z.object({
|
|
1471
|
+
repository_scope_id: z.string(),
|
|
1472
|
+
disposition: z.enum(['affected', 'reviewed_no_change', 'context_only', 'unavailable_acknowledged']),
|
|
1473
|
+
reason: z.string(),
|
|
1474
|
+
acknowledgement_id: z.string().nullable().optional(),
|
|
1475
|
+
})).min(1),
|
|
1476
|
+
summary: z.string().optional(),
|
|
1477
|
+
blocked_reason: z.literal('subagents_unavailable').optional(),
|
|
1478
|
+
},
|
|
1479
|
+
}, async (args) => {
|
|
1480
|
+
const missing = requireActiveSessionRun(session, 'record_context_exploration');
|
|
1481
|
+
if (missing)
|
|
1482
|
+
return missing;
|
|
1483
|
+
return recordContextExplorationHandler(handlerDeps, attribution(), session.context, args);
|
|
1484
|
+
});
|
|
1485
|
+
server.registerTool('release_work_paths', {
|
|
1486
|
+
description: 'Release selected reservations, or every reservation owned by this agent when reservation_ids is omitted.',
|
|
1487
|
+
inputSchema: { reservation_ids: z.array(z.string()).optional() },
|
|
1488
|
+
}, async (args) => {
|
|
1489
|
+
resetSessionIdleTimer(session);
|
|
1490
|
+
return releaseWorkPathsHandler(handlerDeps, session.context, args);
|
|
1491
|
+
});
|
|
1492
|
+
server.registerTool('transfer_coordination', {
|
|
1493
|
+
description: 'Hand coordinator authority to an active collaborator on this task. The caller remains joined as a collaborator.',
|
|
1494
|
+
inputSchema: { target_run_id: z.string() },
|
|
1495
|
+
}, async (args) => {
|
|
1496
|
+
resetSessionIdleTimer(session);
|
|
1497
|
+
return transferCoordinationHandler(handlerDeps, session.context, args);
|
|
1498
|
+
});
|
|
1499
|
+
server.registerTool('acknowledge_unavailable_checkout', {
|
|
1500
|
+
description: 'Record that one project repository could not be inspected locally after warning the user and offering to fetch it. This never pretends the repository was reviewed.',
|
|
1501
|
+
inputSchema: { repository_scope_id: z.string(), reason: z.string() },
|
|
1502
|
+
}, async (args) => {
|
|
1503
|
+
resetSessionIdleTimer(session);
|
|
1504
|
+
return acknowledgeUnavailableCheckoutHandler(handlerDeps, session.context, args);
|
|
1505
|
+
});
|
|
1506
|
+
server.registerTool('end_work', {
|
|
1507
|
+
description: 'End only this agent run after verifying its final task revision and persisted artifact receipt. Other collaborators remain active.',
|
|
1508
|
+
inputSchema: {
|
|
1509
|
+
reason: z.string().optional(),
|
|
1510
|
+
persistence_receipt: z.object({
|
|
1511
|
+
final_task_revision: z.number().int().positive(),
|
|
1512
|
+
artifact_ids: z.array(z.string()).min(1),
|
|
1513
|
+
outcome: z.enum(['completed', 'blocked', 'aborted']).optional(),
|
|
1514
|
+
}),
|
|
1515
|
+
},
|
|
1516
|
+
}, async (args) => {
|
|
1517
|
+
const result = await endAgentRunHandler(handlerDeps, session.context, args, { requirePersistenceReceipt: true });
|
|
1518
|
+
if (!result.isError)
|
|
1519
|
+
stopRunTimers(session);
|
|
1520
|
+
return result;
|
|
1521
|
+
});
|
|
1522
|
+
server.registerTool('create_task', {
|
|
1523
|
+
description: 'Create a task in this agent run\'s bound project (status: backlog). Used when a work item is split.',
|
|
1524
|
+
inputSchema: {
|
|
1525
|
+
name: z.string(),
|
|
1526
|
+
description: z.string().optional(),
|
|
1527
|
+
tags: z.array(z.string()).optional(),
|
|
1528
|
+
depends_on: z.array(z.string()).optional().describe('Task ids this new task depends on'),
|
|
1529
|
+
before_task_id: z.string().optional().describe('Insert before this task in the backlog column'),
|
|
1530
|
+
},
|
|
1531
|
+
}, async (args) => {
|
|
1532
|
+
const missing = requireExploredSessionRun(session, 'create_task');
|
|
1533
|
+
if (missing)
|
|
1534
|
+
return missing;
|
|
1535
|
+
const run = session.context.run;
|
|
1536
|
+
return createTaskHandler(bindProject(handlerDeps, run.projectId), attribution(), args);
|
|
1537
|
+
});
|
|
1538
|
+
server.registerTool('update_task', {
|
|
1539
|
+
description: 'Update a task atomically. Pass the revision from get_task; stale updates are rejected so another person’s changes are never silently overwritten. Returns the fresh task.',
|
|
1540
|
+
inputSchema: {
|
|
1541
|
+
id: z.string(),
|
|
1542
|
+
expected_revision: z.number().int().positive().describe('The revision returned by the most recent get_task call'),
|
|
1543
|
+
fields: z.object({
|
|
1544
|
+
name: z.string().optional(),
|
|
1545
|
+
description: z.string().optional(),
|
|
1546
|
+
due_date: z.string().nullable().optional(),
|
|
1547
|
+
owner: z.string().optional().describe('Email or profile id'),
|
|
1548
|
+
status: z.enum(['backlog', 'in_progress', 'done']).optional(),
|
|
1549
|
+
sprint: z.string().nullable().optional().describe('Sprint name — resolved or created'),
|
|
1550
|
+
tags: z.array(z.string()).optional().describe('Full replacement tag set, by name'),
|
|
1551
|
+
before_task_id: z.string().nullable().optional(),
|
|
1552
|
+
}),
|
|
1553
|
+
},
|
|
1554
|
+
}, async (args) => {
|
|
1555
|
+
const missing = requireExploredSessionRun(session, 'update_task');
|
|
1556
|
+
if (missing)
|
|
1557
|
+
return missing;
|
|
1558
|
+
const run = session.context.run;
|
|
1559
|
+
return updateTaskHandler(bindProject(handlerDeps, run.projectId), attribution(), args, run);
|
|
1560
|
+
});
|
|
1561
|
+
server.registerTool('create_artifact', {
|
|
1562
|
+
description: 'Persist an analysis/plan/spec/diagram/mock/wireframe on a task after context exploration. Never write planning documents into a repository.',
|
|
1563
|
+
inputSchema: {
|
|
1564
|
+
task_id: z.string(),
|
|
1565
|
+
type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe']),
|
|
1566
|
+
format: z.enum(['md', 'html', 'json', 'svg']).optional().describe('Defaults to md'),
|
|
1567
|
+
content: z.string(),
|
|
1568
|
+
coverage: z.array(z.object({
|
|
1569
|
+
repository_scope_id: z.string(),
|
|
1570
|
+
disposition: z.enum(['affected', 'reviewed_no_change', 'context_only', 'unavailable_acknowledged']),
|
|
1571
|
+
reason: z.string(),
|
|
1572
|
+
acknowledgement_id: z.string().nullable().optional(),
|
|
1573
|
+
})).min(1).describe('Exactly one disposition for every active repository scope returned by get_task'),
|
|
1574
|
+
},
|
|
1575
|
+
}, async (args) => {
|
|
1576
|
+
const missing = requireExploredSessionRun(session, 'create_artifact');
|
|
1577
|
+
if (missing)
|
|
1578
|
+
return missing;
|
|
1579
|
+
const run = session.context.run;
|
|
1580
|
+
return createArtifactHandler(bindProject(handlerDeps, run.projectId), attribution(), args, run);
|
|
1581
|
+
});
|
|
1582
|
+
server.registerTool('ask_question', {
|
|
1583
|
+
description: 'Persist a categorized, actionable question after complete context exploration. Questions are comments on the current work item.',
|
|
1584
|
+
inputSchema: {
|
|
1585
|
+
task_id: z.string(),
|
|
1586
|
+
category: z.enum(['clarification', 'reproduction', 'intent', 'dependency', 'checkout', 'collision', 'split']),
|
|
1587
|
+
question: z.string(),
|
|
1588
|
+
},
|
|
1589
|
+
}, async (args) => {
|
|
1590
|
+
const missing = requireExploredSessionRun(session, 'ask_question');
|
|
1591
|
+
if (missing)
|
|
1592
|
+
return missing;
|
|
1593
|
+
const run = session.context.run;
|
|
1594
|
+
return askQuestionHandler(handlerDeps, attribution(), args, run);
|
|
1595
|
+
});
|
|
1596
|
+
server.registerTool('add_comment', {
|
|
1597
|
+
description: 'Legacy question-only alias. Agent progress narration is not persisted; use ask_question for categorized questions.',
|
|
1598
|
+
inputSchema: { task_id: z.string(), body: z.string() },
|
|
1599
|
+
}, async (args) => {
|
|
1600
|
+
const missing = requireExploredSessionRun(session, 'add_comment');
|
|
1601
|
+
if (missing)
|
|
1602
|
+
return missing;
|
|
1603
|
+
const run = session.context.run;
|
|
1604
|
+
if (args.task_id !== run.taskId) {
|
|
1605
|
+
return errorResult(`add_comment must stay on this agent run's task (${run.taskId}).`);
|
|
1606
|
+
}
|
|
1607
|
+
if (!isRealQuestion(args.body)) {
|
|
1608
|
+
return errorResult('add_comment is question-only for agent sessions. Use ask_question with real question text.');
|
|
1609
|
+
}
|
|
1610
|
+
return askQuestionHandler(handlerDeps, attribution(), {
|
|
1611
|
+
task_id: args.task_id,
|
|
1612
|
+
category: 'clarification',
|
|
1613
|
+
question: args.body,
|
|
1614
|
+
}, run);
|
|
1615
|
+
});
|
|
1616
|
+
server.registerPrompt('ctrl-spc-protocol', { description: 'The full §5a agent working protocol.' }, async () => {
|
|
1617
|
+
resetSessionIdleTimer(session);
|
|
1618
|
+
return protocolPromptHandler();
|
|
1619
|
+
});
|
|
1620
|
+
return server;
|
|
1621
|
+
}
|
|
1622
|
+
const sessions = new Map();
|
|
1623
|
+
async function handleHttp(req, res) {
|
|
1624
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
1625
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
1626
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
1627
|
+
res.end(JSON.stringify({ status: 'ok', machine_id: machineId, version: BROKER_VERSION }));
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (url.pathname !== '/mcp') {
|
|
1631
|
+
res.writeHead(404).end('Not found');
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
try {
|
|
1635
|
+
const sessionIdHeader = req.headers['mcp-session-id'];
|
|
1636
|
+
const sessionId = typeof sessionIdHeader === 'string' ? sessionIdHeader : undefined;
|
|
1637
|
+
const existing = sessionId ? sessions.get(sessionId) : undefined;
|
|
1638
|
+
if (existing) {
|
|
1639
|
+
await existing.transport.handleRequest(req, res);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
if (req.method !== 'POST') {
|
|
1643
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('Bad Request: no valid session ID provided');
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
const body = await readJsonBody(req);
|
|
1647
|
+
if (!isInitializeRequest(body)) {
|
|
1648
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' }).end('Bad Request: no valid session ID provided');
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
const context = {
|
|
1652
|
+
sessionId: randomUUID(),
|
|
1653
|
+
machineId,
|
|
1654
|
+
agentKind: 'unknown',
|
|
1655
|
+
clientName: '',
|
|
1656
|
+
clientVersion: '',
|
|
1657
|
+
run: null,
|
|
1658
|
+
exploration: null,
|
|
1659
|
+
};
|
|
1660
|
+
let managed;
|
|
1661
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1662
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1663
|
+
onsessioninitialized: (sid) => {
|
|
1664
|
+
context.sessionId = sid;
|
|
1665
|
+
sessions.set(sid, managed);
|
|
1666
|
+
},
|
|
1667
|
+
// Security: reject requests whose Host header doesn't match the
|
|
1668
|
+
// server we actually bound (localhost-only, see `httpServer.listen`
|
|
1669
|
+
// below) — closes the DNS-rebinding hole where a page on a public
|
|
1670
|
+
// domain that resolves to 127.0.0.1 could otherwise drive this MCP
|
|
1671
|
+
// server from a victim's browser.
|
|
1672
|
+
enableDnsRebindingProtection: true,
|
|
1673
|
+
allowedHosts: [`localhost:${port}`, `127.0.0.1:${port}`],
|
|
1674
|
+
});
|
|
1675
|
+
managed = { transport, context };
|
|
1676
|
+
transport.onclose = () => {
|
|
1677
|
+
const sid = transport.sessionId;
|
|
1678
|
+
if (sid)
|
|
1679
|
+
sessions.delete(sid);
|
|
1680
|
+
void endManagedSession(managed, 'MCP transport closed');
|
|
1681
|
+
};
|
|
1682
|
+
const server = buildServer(managed);
|
|
1683
|
+
await server.connect(transport);
|
|
1684
|
+
await transport.handleRequest(req, res, body);
|
|
1685
|
+
}
|
|
1686
|
+
catch (err) {
|
|
1687
|
+
if (!res.headersSent) {
|
|
1688
|
+
res
|
|
1689
|
+
.writeHead(500, { 'Content-Type': 'application/json' })
|
|
1690
|
+
.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: err.message }, id: null }));
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
const httpServer = createHttpServer((req, res) => {
|
|
1695
|
+
void handleHttp(req, res);
|
|
1696
|
+
});
|
|
1697
|
+
// Security: bind loopback-only (matches login.ts's callback server) — an
|
|
1698
|
+
// unqualified `listen(port, cb)` binds all interfaces (`::`), exposing the
|
|
1699
|
+
// write-back tool surface to anything on the local network.
|
|
1700
|
+
//
|
|
1701
|
+
// A bare `listen(port, resolve)` also has no failure path: if the port is
|
|
1702
|
+
// already taken (e.g. a second `ctrl-spc` instance in another repo), Node
|
|
1703
|
+
// emits an unhandled 'error' event and the process dies with a raw
|
|
1704
|
+
// EADDRINUSE stack — after presence has already been tracked as
|
|
1705
|
+
// "available". The 'error'/'listening' race below turns that into a
|
|
1706
|
+
// rejected promise so the caller (run.ts) can untrack presence and print a
|
|
1707
|
+
// friendly message before exiting.
|
|
1708
|
+
await new Promise((resolve, reject) => {
|
|
1709
|
+
const onError = (err) => {
|
|
1710
|
+
httpServer.off('listening', onListening);
|
|
1711
|
+
reject(err);
|
|
1712
|
+
};
|
|
1713
|
+
const onListening = () => {
|
|
1714
|
+
httpServer.off('error', onError);
|
|
1715
|
+
resolve();
|
|
1716
|
+
};
|
|
1717
|
+
httpServer.once('error', onError);
|
|
1718
|
+
httpServer.once('listening', onListening);
|
|
1719
|
+
httpServer.listen(port, '127.0.0.1');
|
|
1720
|
+
});
|
|
1721
|
+
return {
|
|
1722
|
+
async close() {
|
|
1723
|
+
const activeSessions = [...sessions.values()];
|
|
1724
|
+
await Promise.all(activeSessions.map((session) => endManagedSession(session, 'CTRL+SPC broker stopped').catch(() => { })));
|
|
1725
|
+
await Promise.all(activeSessions.map((session) => session.transport.close().catch(() => { })));
|
|
1726
|
+
sessions.clear();
|
|
1727
|
+
await new Promise((resolve, reject) => {
|
|
1728
|
+
httpServer.close((err) => (err ? reject(err) : resolve()));
|
|
1729
|
+
});
|
|
1730
|
+
},
|
|
1731
|
+
};
|
|
1732
|
+
}
|