pluriply 0.1.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/LICENSE-HUB.md +38 -0
- package/LICENSE.md +32 -0
- package/README.md +76 -0
- package/bin/pluriply.js +189 -0
- package/package.json +26 -0
- package/src/connector/hub-client.js +327 -0
- package/src/connector/mcp-server.js +51 -0
- package/src/connector/tools.js +727 -0
- package/src/hub/index.js +11 -0
- package/src/setup/clients.js +202 -0
- package/src/setup/run-setup.js +82 -0
- package/src/shared/agy.js +17 -0
- package/src/shared/identity.js +55 -0
- package/src/shared/ids.js +27 -0
- package/src/shared/mcp-register.js +79 -0
- package/src/shared/paths.js +7 -0
- package/src/shared/probe.js +54 -0
- package/src/shared/version.js +18 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** @param {object} data @returns {object} MCP 텍스트 결과 */
|
|
4
|
+
function ok(data) {
|
|
5
|
+
return {
|
|
6
|
+
isError: false,
|
|
7
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* MCP 도구 annotations. 클라이언트(특히 codex)는 annotations 가 없는 도구를 "파괴적·외부 접근"으로
|
|
14
|
+
* 간주해 비대화 실행에서 승인을 요구한다(readOnlyHint=false, destructiveHint=true 가 기본값).
|
|
15
|
+
* pluriply 도구는 전부 로컬 허브만 건드리고 되돌릴 수 있으므로 기본을 비파괴·로컬로 두고,
|
|
16
|
+
* 읽기 전용 도구만 readOnlyHint 를 켠다.
|
|
17
|
+
* @param {object} [o] @returns {object}
|
|
18
|
+
*/
|
|
19
|
+
function annotations(o = {}) {
|
|
20
|
+
return {
|
|
21
|
+
readOnlyHint: false,
|
|
22
|
+
destructiveHint: false,
|
|
23
|
+
idempotentHint: false,
|
|
24
|
+
openWorldHint: false,
|
|
25
|
+
...o,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @param {string} message @returns {object} MCP 에러 결과 */
|
|
30
|
+
function fail(message) {
|
|
31
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ASK_DEFAULT_WAIT_S = 50;
|
|
35
|
+
const ASK_MAX_WAIT_S = 300;
|
|
36
|
+
/** task.wait 한 번의 최대 보류 시간. 허브 상한(15초)보다 짧게 둬 요청 타임아웃과 겹치지 않게 한다. */
|
|
37
|
+
const ASK_CHUNK_MS = 10_000;
|
|
38
|
+
const FINAL_TASK = new Set(["completed", "failed", "cancelled"]);
|
|
39
|
+
|
|
40
|
+
/** @param {unknown} v @returns {number} 숫자가 아니면 기본 50, 1~300 으로 클램프 */
|
|
41
|
+
export function clampWaitSeconds(v) {
|
|
42
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
43
|
+
if (!Number.isFinite(n)) return ASK_DEFAULT_WAIT_S;
|
|
44
|
+
return Math.min(Math.max(n, 1), ASK_MAX_WAIT_S);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {{version: string, pid: number}} stale @returns {string} */
|
|
48
|
+
function staleHubMessage(stale) {
|
|
49
|
+
return (
|
|
50
|
+
`Pluriply hub is running an older version (${stale.version}, pid ${stale.pid}). ` +
|
|
51
|
+
"Run `pluriply hub restart` to upgrade; other sessions attached to that hub will need to restart their tool."
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 허브에 정체성을 알리고 인스턴스 ID를 받는다. 재접속 때는 알고 있는 ID를 실어 그대로 인정받는다.
|
|
57
|
+
* @param {import('./hub-client.js').HubClient} hub
|
|
58
|
+
* @param {{agent: string, worker?: boolean, instanceId?: string, duringReconnect?: boolean}} opts
|
|
59
|
+
* @returns {Promise<string>}
|
|
60
|
+
*/
|
|
61
|
+
export async function hello(
|
|
62
|
+
hub,
|
|
63
|
+
{ agent, worker = false, instanceId, duringReconnect = false },
|
|
64
|
+
) {
|
|
65
|
+
const r = await hub.request(
|
|
66
|
+
"agent.hello",
|
|
67
|
+
{ tool: agent, cwd: process.cwd(), worker, instanceId },
|
|
68
|
+
{ duringReconnect },
|
|
69
|
+
);
|
|
70
|
+
return r.instanceId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pluriply MCP 도구를 등록한다.
|
|
75
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
76
|
+
* @param {import('./hub-client.js').HubClient} hub
|
|
77
|
+
* @param {{agent: string, instanceId: string}} identity
|
|
78
|
+
*/
|
|
79
|
+
export function registerTools(server, hub, { agent, instanceId }) {
|
|
80
|
+
const state = { currentChannel: null };
|
|
81
|
+
const worker = Boolean(process.env.PLURIPLY_WORKER_TASK);
|
|
82
|
+
/** 워커는 자기 태스크 깊이 + 1, 대화형 세션은 0 */
|
|
83
|
+
const delegationDepth = () =>
|
|
84
|
+
worker ? Number(process.env.PLURIPLY_DEPTH ?? 0) + 1 : 0;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 채널이 없을 때 허브에 직전 채널 복귀를 요청한다.
|
|
88
|
+
* @returns {Promise<string|null>} 복귀한 채널 코드
|
|
89
|
+
*/
|
|
90
|
+
async function resumeChannel() {
|
|
91
|
+
const { channelCode } = await hub.request("agent.resume", {});
|
|
92
|
+
if (!channelCode) return null;
|
|
93
|
+
if (state.currentChannel) return null; // join_channel이 경합에서 이겼으니 그대로 둔다
|
|
94
|
+
state.currentChannel = channelCode;
|
|
95
|
+
return channelCode;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 채널 가드. 핸들러는 순수 데이터를 반환하고 여기서 MCP 결과로 포장한다.
|
|
100
|
+
* 채널이 없으면 복귀를 시도하고, 복귀한 그 응답에만 resumedChannel을 붙인다.
|
|
101
|
+
*/
|
|
102
|
+
const needChannel = (fn) => async (args, extra) => {
|
|
103
|
+
if (hub.stale) return fail(staleHubMessage(hub.stale));
|
|
104
|
+
let resumed = null;
|
|
105
|
+
if (!state.currentChannel) {
|
|
106
|
+
try {
|
|
107
|
+
resumed = await resumeChannel();
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return fail(
|
|
110
|
+
`Join a channel first with join_channel. (auto-resume failed: ${err.message})`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!state.currentChannel)
|
|
115
|
+
return fail("Join a channel first with join_channel.");
|
|
116
|
+
try {
|
|
117
|
+
const data = await fn(args, state.currentChannel, extra);
|
|
118
|
+
return ok(resumed ? { ...data, resumedChannel: resumed } : data);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return fail(
|
|
121
|
+
resumed
|
|
122
|
+
? `${err.message} (channel ${resumed} was resumed automatically)`
|
|
123
|
+
: err.message,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* task.wait 를 반복하며 최종 상태를 기다린다(ask_agent·request_review 공용).
|
|
130
|
+
* @returns {Promise<{status: string, taskId: string, result?: unknown, taskStatus?: string, hint?: string}>}
|
|
131
|
+
*/
|
|
132
|
+
async function waitForTask({ code, taskId, waitS, extra, label }) {
|
|
133
|
+
const signal = extra?.signal;
|
|
134
|
+
const progressToken = extra?._meta?.progressToken;
|
|
135
|
+
const deadline = Date.now() + waitS * 1000;
|
|
136
|
+
let task = null;
|
|
137
|
+
let lastProgress = -1;
|
|
138
|
+
try {
|
|
139
|
+
while (!signal?.aborted) {
|
|
140
|
+
const remaining = deadline - Date.now();
|
|
141
|
+
if (remaining <= 0) break;
|
|
142
|
+
const timeoutMs = Math.min(ASK_CHUNK_MS, remaining);
|
|
143
|
+
({ task } = await hub.request(
|
|
144
|
+
"task.wait",
|
|
145
|
+
{ channelCode: code, taskId, timeoutMs },
|
|
146
|
+
{ timeoutMs: timeoutMs + 5000 },
|
|
147
|
+
));
|
|
148
|
+
if (FINAL_TASK.has(task.status))
|
|
149
|
+
return { status: task.status, taskId, result: task.result };
|
|
150
|
+
if (progressToken !== undefined && extra?.sendNotification) {
|
|
151
|
+
const elapsed = Math.min(
|
|
152
|
+
waitS,
|
|
153
|
+
Math.round((waitS * 1000 - (deadline - Date.now())) / 1000),
|
|
154
|
+
);
|
|
155
|
+
if (elapsed > lastProgress) {
|
|
156
|
+
lastProgress = elapsed;
|
|
157
|
+
await extra
|
|
158
|
+
.sendNotification({
|
|
159
|
+
method: "notifications/progress",
|
|
160
|
+
params: {
|
|
161
|
+
progressToken,
|
|
162
|
+
progress: elapsed,
|
|
163
|
+
total: waitS,
|
|
164
|
+
message: `waiting for ${label} (task ${taskId})`,
|
|
165
|
+
},
|
|
166
|
+
})
|
|
167
|
+
.catch(() => {});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`${err.message} (task ${taskId} may still be running; use get_task_result or list_tasks with sent_by_me: true)`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
status: "running",
|
|
178
|
+
taskId,
|
|
179
|
+
taskStatus: task?.status ?? "submitted",
|
|
180
|
+
hint: `still running; call get_task_result with task_id "${taskId}" (or cancel_task)`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 허브가 재시작되면 현재 채널에 다시 참여한다 (hub-client가 reconnected를 낸다)
|
|
185
|
+
if (typeof hub.on === "function") {
|
|
186
|
+
hub.on("reconnected", async () => {
|
|
187
|
+
if (!state.currentChannel) return;
|
|
188
|
+
try {
|
|
189
|
+
// duringReconnect: true — 이 리스너 자체가 hub-client의 재접속 배리어이므로,
|
|
190
|
+
// 여기서 나가는 request()가 this.reconnecting을 기다리면 자기 자신을
|
|
191
|
+
// 기다리는 교착 상태가 된다.
|
|
192
|
+
// 새 소켓은 정체성이 없으므로 channel.join보다 먼저 hello로 인스턴스
|
|
193
|
+
// ID를 다시 인정받아야 한다(그렇지 않으면 "say hello first").
|
|
194
|
+
await hello(hub, { agent, worker, instanceId, duringReconnect: true });
|
|
195
|
+
await hub.request(
|
|
196
|
+
"channel.join",
|
|
197
|
+
{ channelCode: state.currentChannel },
|
|
198
|
+
{ duringReconnect: true },
|
|
199
|
+
);
|
|
200
|
+
hub.emit("rejoined", state.currentChannel);
|
|
201
|
+
} catch {
|
|
202
|
+
// 채널이 사라졌으면 다음 도구 호출이 알려준다
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
server.registerTool(
|
|
208
|
+
"join_channel",
|
|
209
|
+
{
|
|
210
|
+
annotations: annotations({ idempotentHint: true }),
|
|
211
|
+
description:
|
|
212
|
+
"Join a Pluriply collaboration channel shared by your other AI tools. " +
|
|
213
|
+
"Omit channel_code to create a new channel; share the returned code with other tools so they can join. " +
|
|
214
|
+
"Calling this always overrides any automatically resumed channel." +
|
|
215
|
+
" The response's me is your instanceId; other tools can pin tasks to it.",
|
|
216
|
+
inputSchema: { channel_code: z.string().optional() },
|
|
217
|
+
},
|
|
218
|
+
async ({ channel_code }) => {
|
|
219
|
+
if (hub.stale) return fail(staleHubMessage(hub.stale));
|
|
220
|
+
try {
|
|
221
|
+
const code =
|
|
222
|
+
channel_code ?? (await hub.request("channel.create")).channelCode;
|
|
223
|
+
const { peers } = await hub.request("channel.join", {
|
|
224
|
+
channelCode: code,
|
|
225
|
+
});
|
|
226
|
+
state.currentChannel = code;
|
|
227
|
+
return ok({ channelCode: code, peers, me: instanceId });
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return fail(err.message);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
server.registerTool(
|
|
235
|
+
"list_peers",
|
|
236
|
+
{
|
|
237
|
+
annotations: annotations({ readOnlyHint: true, idempotentHint: true }),
|
|
238
|
+
description:
|
|
239
|
+
"List the AI tools connected to the joined channel. Each entry is one session (instanceId); the same tool may appear several times.",
|
|
240
|
+
inputSchema: {},
|
|
241
|
+
},
|
|
242
|
+
needChannel((_args, code) =>
|
|
243
|
+
hub.request("channel.peers", { channelCode: code }),
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
server.registerTool(
|
|
248
|
+
"channel_status",
|
|
249
|
+
{
|
|
250
|
+
annotations: annotations({ readOnlyHint: true, idempotentHint: true }),
|
|
251
|
+
description:
|
|
252
|
+
"Show the joined channel code, its peers, and how many tasks await this agent.",
|
|
253
|
+
inputSchema: {},
|
|
254
|
+
},
|
|
255
|
+
needChannel(async (_args, code) => {
|
|
256
|
+
const { peers } = await hub.request("channel.peers", {
|
|
257
|
+
channelCode: code,
|
|
258
|
+
});
|
|
259
|
+
const { tasks } = await hub.request("task.list", {
|
|
260
|
+
channelCode: code,
|
|
261
|
+
to: instanceId,
|
|
262
|
+
status: "submitted",
|
|
263
|
+
});
|
|
264
|
+
const { running } = await hub.request("worker.status", {
|
|
265
|
+
channelCode: code,
|
|
266
|
+
});
|
|
267
|
+
return {
|
|
268
|
+
channelCode: code,
|
|
269
|
+
peers,
|
|
270
|
+
pendingTaskCount: tasks.length,
|
|
271
|
+
runningWorkers: running,
|
|
272
|
+
};
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
server.registerTool(
|
|
277
|
+
"send_task",
|
|
278
|
+
{
|
|
279
|
+
annotations: annotations(),
|
|
280
|
+
description:
|
|
281
|
+
'Delegate a task to another AI tool on the channel (e.g. to: "codex"). ' +
|
|
282
|
+
"Returns a taskId; poll get_task_result with it to collect the outcome. " +
|
|
283
|
+
"dispatch tells you what happened: interactive = a live session of that tool will handle it (ask the user to nudge it), " +
|
|
284
|
+
"spawned/queued = the hub started a headless worker, none = nothing will process it (the hint says how to enable a worker). " +
|
|
285
|
+
"pinned = the task is fixed to that instance (no worker will be spawned); targetOnline tells whether it is connected right now. " +
|
|
286
|
+
"Always pass cwd as the folder the work should happen in. " +
|
|
287
|
+
"If targetJoined is false the target has not joined yet. Agent names are case-sensitive.",
|
|
288
|
+
inputSchema: {
|
|
289
|
+
to: z
|
|
290
|
+
.string()
|
|
291
|
+
.describe(
|
|
292
|
+
'Target: a tool name (e.g. "codex") for any session of that tool, or an instanceId from list_peers (e.g. "codex#k7pq") to pin the task to one session',
|
|
293
|
+
),
|
|
294
|
+
request: z
|
|
295
|
+
.string()
|
|
296
|
+
.describe(
|
|
297
|
+
"What you want the other tool to do, with all needed context",
|
|
298
|
+
),
|
|
299
|
+
attachments: z
|
|
300
|
+
.array(z.string())
|
|
301
|
+
.optional()
|
|
302
|
+
.describe("Absolute file paths to share"),
|
|
303
|
+
mode: z
|
|
304
|
+
.enum(["auto", "spawn", "interactive"])
|
|
305
|
+
.optional()
|
|
306
|
+
.describe(
|
|
307
|
+
"auto (default): spawn a worker only if no live session; spawn: always spawn; interactive: never spawn",
|
|
308
|
+
),
|
|
309
|
+
cwd: z
|
|
310
|
+
.string()
|
|
311
|
+
.optional()
|
|
312
|
+
.describe(
|
|
313
|
+
"Absolute working directory for the task (default: this session's cwd)." +
|
|
314
|
+
" Must be inside this session's working directory or one of config.json allowedRoots.",
|
|
315
|
+
),
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
needChannel(
|
|
319
|
+
(
|
|
320
|
+
{ to, request, attachments = [], mode = "auto", cwd = process.cwd() },
|
|
321
|
+
code,
|
|
322
|
+
) =>
|
|
323
|
+
hub.request("task.create", {
|
|
324
|
+
channelCode: code,
|
|
325
|
+
to,
|
|
326
|
+
request,
|
|
327
|
+
attachments,
|
|
328
|
+
mode,
|
|
329
|
+
cwd,
|
|
330
|
+
origin: process.cwd(),
|
|
331
|
+
depth: delegationDepth(),
|
|
332
|
+
}),
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
server.registerTool(
|
|
337
|
+
"ask_agent",
|
|
338
|
+
{
|
|
339
|
+
annotations: annotations(),
|
|
340
|
+
description:
|
|
341
|
+
'Ask another AI tool something and wait for the answer (e.g. to: "codex"). ' +
|
|
342
|
+
"The hub always starts a headless worker of that tool (it must be enabled with `pluriply worker enable <tool>`), " +
|
|
343
|
+
"regardless of live sessions. Waits up to wait_seconds (default 50, max 300) and returns status completed/failed/cancelled with result. " +
|
|
344
|
+
"If it is still running you get status running plus taskId: call get_task_result later. " +
|
|
345
|
+
"taskStatus on a running response (submitted/working) tells whether the worker has claimed the task yet. " +
|
|
346
|
+
"status not_started means no worker could start (hint says why) and the task was cancelled. " +
|
|
347
|
+
"Use send_task instead for long jobs or to pin a specific instance. Always pass cwd as the folder the work should happen in.",
|
|
348
|
+
inputSchema: {
|
|
349
|
+
to: z
|
|
350
|
+
.string()
|
|
351
|
+
.describe(
|
|
352
|
+
'Tool name only (e.g. "codex", "antigravity"); instance ids are rejected',
|
|
353
|
+
),
|
|
354
|
+
request: z
|
|
355
|
+
.string()
|
|
356
|
+
.describe("The question or task, with all needed context"),
|
|
357
|
+
attachments: z
|
|
358
|
+
.array(z.string())
|
|
359
|
+
.optional()
|
|
360
|
+
.describe("Absolute file paths to share"),
|
|
361
|
+
cwd: z
|
|
362
|
+
.string()
|
|
363
|
+
.optional()
|
|
364
|
+
.describe(
|
|
365
|
+
"Absolute working directory for the worker (default: this session's cwd)." +
|
|
366
|
+
" Must be inside this session's working directory or one of config.json allowedRoots.",
|
|
367
|
+
),
|
|
368
|
+
wait_seconds: z
|
|
369
|
+
.number()
|
|
370
|
+
.optional()
|
|
371
|
+
.describe(
|
|
372
|
+
"How long to wait for the answer (default 50, max 300; keep under 60 when calling from Codex or Gemini)",
|
|
373
|
+
),
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
needChannel(
|
|
377
|
+
async (
|
|
378
|
+
{ to, request, attachments = [], cwd = process.cwd(), wait_seconds },
|
|
379
|
+
code,
|
|
380
|
+
extra,
|
|
381
|
+
) => {
|
|
382
|
+
if (typeof to === "string" && to.includes("#"))
|
|
383
|
+
throw new Error(
|
|
384
|
+
`ask_agent takes a tool name (e.g. "codex"); use send_task to pin an instance like "${to}"`,
|
|
385
|
+
);
|
|
386
|
+
const waitS = clampWaitSeconds(wait_seconds);
|
|
387
|
+
const created = await hub.request("task.create", {
|
|
388
|
+
channelCode: code,
|
|
389
|
+
to,
|
|
390
|
+
request,
|
|
391
|
+
attachments,
|
|
392
|
+
mode: "spawn",
|
|
393
|
+
cwd,
|
|
394
|
+
origin: process.cwd(),
|
|
395
|
+
depth: delegationDepth(),
|
|
396
|
+
});
|
|
397
|
+
const { taskId } = created;
|
|
398
|
+
if (created.dispatch !== "spawned" && created.dispatch !== "queued") {
|
|
399
|
+
// 워커가 뜨지 않았다: 쓰레기 submitted 태스크를 남기지 않는다
|
|
400
|
+
await hub
|
|
401
|
+
.request("task.cancel", {
|
|
402
|
+
channelCode: code,
|
|
403
|
+
taskId,
|
|
404
|
+
reason: "ask_agent: worker not started",
|
|
405
|
+
})
|
|
406
|
+
.catch(() => {});
|
|
407
|
+
return {
|
|
408
|
+
status: "not_started",
|
|
409
|
+
taskId,
|
|
410
|
+
hint: created.hint ?? `dispatch: ${created.dispatch}`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return waitForTask({ code, taskId, waitS, extra, label: `${to} worker` });
|
|
414
|
+
},
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
server.registerTool(
|
|
419
|
+
"request_review",
|
|
420
|
+
{
|
|
421
|
+
annotations: annotations(),
|
|
422
|
+
description:
|
|
423
|
+
"Ask another AI tool to review work in this project and return a structured verdict. " +
|
|
424
|
+
'Target: git_range (e.g. "master..HEAD", "HEAD~3") and/or paths; neither means the uncommitted changes (git diff HEAD). ' +
|
|
425
|
+
'focus sets the angle (e.g. "security", "quality"). The reviewer runs read-only. ' +
|
|
426
|
+
"By default returns a taskId immediately (dispatch tells you who will review; poll get_task_result — result holds {verdict, findings[], summary}). " +
|
|
427
|
+
"Pass wait_seconds (max 300) to wait like ask_agent: status completed comes with review, running comes with taskId. " +
|
|
428
|
+
"to may be a tool name or an instanceId from list_peers. Always pass cwd as the project folder.",
|
|
429
|
+
inputSchema: {
|
|
430
|
+
to: z
|
|
431
|
+
.string()
|
|
432
|
+
.describe('Tool name (e.g. "codex") or instanceId (e.g. "codex#k7pq")'),
|
|
433
|
+
request: z
|
|
434
|
+
.string()
|
|
435
|
+
.optional()
|
|
436
|
+
.describe("Extra instructions for the reviewer"),
|
|
437
|
+
git_range: z
|
|
438
|
+
.string()
|
|
439
|
+
.optional()
|
|
440
|
+
.describe(
|
|
441
|
+
'Git revision range to review, e.g. "master..HEAD" or "HEAD~1"',
|
|
442
|
+
),
|
|
443
|
+
paths: z
|
|
444
|
+
.array(z.string())
|
|
445
|
+
.optional()
|
|
446
|
+
.describe("Files or folders to review (absolute or relative to cwd)"),
|
|
447
|
+
focus: z
|
|
448
|
+
.string()
|
|
449
|
+
.optional()
|
|
450
|
+
.describe('Review angle, e.g. "security", "performance"'),
|
|
451
|
+
cwd: z
|
|
452
|
+
.string()
|
|
453
|
+
.optional()
|
|
454
|
+
.describe(
|
|
455
|
+
"Absolute project folder (default: this session's cwd). Must be inside this session's working directory or one of config.json allowedRoots.",
|
|
456
|
+
),
|
|
457
|
+
mode: z
|
|
458
|
+
.enum(["auto", "spawn", "interactive"])
|
|
459
|
+
.optional()
|
|
460
|
+
.describe(
|
|
461
|
+
"auto (default): spawn a worker only if no live session; spawn: always spawn; interactive: never spawn",
|
|
462
|
+
),
|
|
463
|
+
wait_seconds: z
|
|
464
|
+
.number()
|
|
465
|
+
.optional()
|
|
466
|
+
.describe(
|
|
467
|
+
"If set, wait up to this many seconds (1-300) for the review",
|
|
468
|
+
),
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
needChannel(
|
|
472
|
+
async (
|
|
473
|
+
{
|
|
474
|
+
to,
|
|
475
|
+
request,
|
|
476
|
+
git_range,
|
|
477
|
+
paths,
|
|
478
|
+
focus,
|
|
479
|
+
cwd = process.cwd(),
|
|
480
|
+
mode = "auto",
|
|
481
|
+
wait_seconds,
|
|
482
|
+
},
|
|
483
|
+
code,
|
|
484
|
+
extra,
|
|
485
|
+
) => {
|
|
486
|
+
const review = {};
|
|
487
|
+
if (git_range !== undefined) review.gitRange = git_range;
|
|
488
|
+
if (paths !== undefined) review.paths = paths;
|
|
489
|
+
if (focus !== undefined) review.focus = focus;
|
|
490
|
+
const created = await hub.request("task.create", {
|
|
491
|
+
channelCode: code,
|
|
492
|
+
to,
|
|
493
|
+
request: request ?? "",
|
|
494
|
+
attachments: [],
|
|
495
|
+
kind: "review",
|
|
496
|
+
review,
|
|
497
|
+
mode,
|
|
498
|
+
cwd,
|
|
499
|
+
origin: process.cwd(),
|
|
500
|
+
depth: delegationDepth(),
|
|
501
|
+
});
|
|
502
|
+
if (wait_seconds === undefined) return created;
|
|
503
|
+
const { taskId } = created;
|
|
504
|
+
if (created.dispatch === "none") {
|
|
505
|
+
await hub
|
|
506
|
+
.request("task.cancel", {
|
|
507
|
+
channelCode: code,
|
|
508
|
+
taskId,
|
|
509
|
+
reason: "request_review: worker not started",
|
|
510
|
+
})
|
|
511
|
+
.catch(() => {});
|
|
512
|
+
return {
|
|
513
|
+
status: "not_started",
|
|
514
|
+
taskId,
|
|
515
|
+
hint: created.hint ?? `dispatch: ${created.dispatch}`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const waited = await waitForTask({
|
|
519
|
+
code,
|
|
520
|
+
taskId,
|
|
521
|
+
waitS: clampWaitSeconds(wait_seconds),
|
|
522
|
+
extra,
|
|
523
|
+
label: `${to} review`,
|
|
524
|
+
});
|
|
525
|
+
if (waited.status === "completed") {
|
|
526
|
+
const { result, ...rest } = waited;
|
|
527
|
+
return { ...rest, review: result };
|
|
528
|
+
}
|
|
529
|
+
return waited;
|
|
530
|
+
},
|
|
531
|
+
),
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
server.registerTool(
|
|
535
|
+
"cancel_task",
|
|
536
|
+
{
|
|
537
|
+
annotations: annotations({ idempotentHint: true }),
|
|
538
|
+
description:
|
|
539
|
+
"Cancel a task you sent that has not finished yet. " +
|
|
540
|
+
"Use it to clean up tasks sent to the wrong target or no longer needed. Only the sender can cancel.",
|
|
541
|
+
inputSchema: {
|
|
542
|
+
task_id: z.string(),
|
|
543
|
+
reason: z.string().optional().describe("Why it was cancelled"),
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
needChannel(({ task_id, reason }, code) =>
|
|
547
|
+
hub.request("task.cancel", {
|
|
548
|
+
channelCode: code,
|
|
549
|
+
taskId: task_id,
|
|
550
|
+
reason,
|
|
551
|
+
}),
|
|
552
|
+
),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
server.registerTool(
|
|
556
|
+
"list_tasks",
|
|
557
|
+
{
|
|
558
|
+
annotations: annotations({ readOnlyHint: true, idempotentHint: true }),
|
|
559
|
+
description:
|
|
560
|
+
"List tasks on the channel. By default lists tasks addressed to me. " +
|
|
561
|
+
'Set sent_by_me: true to list tasks I delegated (combine with status: "submitted" to find undelivered ones to cancel_task).',
|
|
562
|
+
inputSchema: {
|
|
563
|
+
status: z
|
|
564
|
+
.enum(["submitted", "working", "completed", "failed", "cancelled"])
|
|
565
|
+
.optional(),
|
|
566
|
+
mine_only: z
|
|
567
|
+
.boolean()
|
|
568
|
+
.optional()
|
|
569
|
+
.describe("Default true: only tasks addressed to me"),
|
|
570
|
+
sent_by_me: z
|
|
571
|
+
.boolean()
|
|
572
|
+
.optional()
|
|
573
|
+
.describe("Only tasks I sent; overrides mine_only"),
|
|
574
|
+
kind: z
|
|
575
|
+
.enum(["task", "review"])
|
|
576
|
+
.optional()
|
|
577
|
+
.describe("Only tasks of this kind"),
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
needChannel(
|
|
581
|
+
({ status, mine_only = true, sent_by_me = false, kind }, code) =>
|
|
582
|
+
hub.request("task.list", {
|
|
583
|
+
channelCode: code,
|
|
584
|
+
to: sent_by_me ? undefined : mine_only ? instanceId : undefined,
|
|
585
|
+
from: sent_by_me ? instanceId : undefined,
|
|
586
|
+
status,
|
|
587
|
+
kind,
|
|
588
|
+
}),
|
|
589
|
+
),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
server.registerTool(
|
|
593
|
+
"get_task_result",
|
|
594
|
+
{
|
|
595
|
+
annotations: annotations({ readOnlyHint: true, idempotentHint: true }),
|
|
596
|
+
description:
|
|
597
|
+
"Fetch a task by id, including its status and result once completed.",
|
|
598
|
+
inputSchema: { task_id: z.string() },
|
|
599
|
+
},
|
|
600
|
+
needChannel(({ task_id }, code) =>
|
|
601
|
+
hub.request("task.get", { channelCode: code, taskId: task_id }),
|
|
602
|
+
),
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
server.registerTool(
|
|
606
|
+
"submit_result",
|
|
607
|
+
{
|
|
608
|
+
annotations: annotations({ idempotentHint: true }),
|
|
609
|
+
description:
|
|
610
|
+
"Submit the result for a task that was delegated to this agent. Review tasks must use submit_review (failed: true is still allowed here).",
|
|
611
|
+
inputSchema: {
|
|
612
|
+
task_id: z.string(),
|
|
613
|
+
result: z
|
|
614
|
+
.string()
|
|
615
|
+
.describe(
|
|
616
|
+
"The outcome: findings, produced file paths, or the deliverable itself",
|
|
617
|
+
),
|
|
618
|
+
failed: z
|
|
619
|
+
.boolean()
|
|
620
|
+
.optional()
|
|
621
|
+
.describe("Set true if the task could not be done"),
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
needChannel(async ({ task_id, result, failed = false }, code) => {
|
|
625
|
+
const { task } = await hub.request("task.get", {
|
|
626
|
+
channelCode: code,
|
|
627
|
+
taskId: task_id,
|
|
628
|
+
});
|
|
629
|
+
if (task.status === "submitted") {
|
|
630
|
+
await hub.request("task.claim", {
|
|
631
|
+
channelCode: code,
|
|
632
|
+
taskId: task_id,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
return hub.request("task.complete", {
|
|
636
|
+
channelCode: code,
|
|
637
|
+
taskId: task_id,
|
|
638
|
+
result,
|
|
639
|
+
status: failed ? "failed" : "completed",
|
|
640
|
+
});
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
server.registerTool(
|
|
645
|
+
"submit_review",
|
|
646
|
+
{
|
|
647
|
+
annotations: annotations({ idempotentHint: true }),
|
|
648
|
+
description:
|
|
649
|
+
"Submit the outcome of a review task delegated to this agent. " +
|
|
650
|
+
"verdict: approve (no critical/important findings), request_changes, or comment. " +
|
|
651
|
+
"Each finding needs severity (critical|important|minor) and message; add file and line when they apply. " +
|
|
652
|
+
"Review tasks cannot be completed with submit_result.",
|
|
653
|
+
inputSchema: {
|
|
654
|
+
task_id: z.string(),
|
|
655
|
+
verdict: z.enum(["approve", "request_changes", "comment"]),
|
|
656
|
+
findings: z
|
|
657
|
+
.array(
|
|
658
|
+
z.object({
|
|
659
|
+
severity: z.enum(["critical", "important", "minor"]),
|
|
660
|
+
file: z.string().optional(),
|
|
661
|
+
line: z.number().int().positive().optional(),
|
|
662
|
+
message: z.string(),
|
|
663
|
+
suggestion: z.string().optional(),
|
|
664
|
+
}),
|
|
665
|
+
)
|
|
666
|
+
.optional(),
|
|
667
|
+
summary: z.string().describe("Short overall assessment"),
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
needChannel(async ({ task_id, verdict, findings = [], summary }, code) => {
|
|
671
|
+
const { task } = await hub.request("task.get", {
|
|
672
|
+
channelCode: code,
|
|
673
|
+
taskId: task_id,
|
|
674
|
+
});
|
|
675
|
+
if (task.status === "submitted") {
|
|
676
|
+
await hub.request("task.claim", { channelCode: code, taskId: task_id });
|
|
677
|
+
}
|
|
678
|
+
return hub.request("task.complete", {
|
|
679
|
+
channelCode: code,
|
|
680
|
+
taskId: task_id,
|
|
681
|
+
status: "completed",
|
|
682
|
+
review: { verdict, findings, summary },
|
|
683
|
+
});
|
|
684
|
+
}),
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
server.registerTool(
|
|
688
|
+
"share_update",
|
|
689
|
+
{
|
|
690
|
+
annotations: annotations(),
|
|
691
|
+
description:
|
|
692
|
+
"Record what you just did into the shared channel context so other tools stay aware.",
|
|
693
|
+
inputSchema: {
|
|
694
|
+
summary: z.string().describe("Short summary of the work done"),
|
|
695
|
+
artifacts: z
|
|
696
|
+
.array(z.string())
|
|
697
|
+
.optional()
|
|
698
|
+
.describe("Absolute file paths of produced artifacts"),
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
needChannel(({ summary, artifacts = [] }, code) =>
|
|
702
|
+
hub.request("context.add", {
|
|
703
|
+
channelCode: code,
|
|
704
|
+
summary,
|
|
705
|
+
artifacts,
|
|
706
|
+
}),
|
|
707
|
+
),
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
server.registerTool(
|
|
711
|
+
"get_channel_context",
|
|
712
|
+
{
|
|
713
|
+
annotations: annotations({ readOnlyHint: true, idempotentHint: true }),
|
|
714
|
+
description:
|
|
715
|
+
"Read the shared work history of the channel: what every connected tool has done and produced.",
|
|
716
|
+
inputSchema: {
|
|
717
|
+
limit: z
|
|
718
|
+
.number()
|
|
719
|
+
.optional()
|
|
720
|
+
.describe("Return only the latest N entries"),
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
needChannel(({ limit }, code) =>
|
|
724
|
+
hub.request("context.list", { channelCode: code, limit }),
|
|
725
|
+
),
|
|
726
|
+
);
|
|
727
|
+
}
|