@dalmasonto/taskflow-mcp 1.0.35 → 2.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/README.md +138 -193
- package/dist/attachment-download.d.ts +74 -0
- package/dist/attachment-download.js +193 -0
- package/dist/attachment-download.js.map +1 -0
- package/dist/attachments.d.ts +23 -0
- package/dist/attachments.js +66 -0
- package/dist/attachments.js.map +1 -0
- package/dist/client.d.ts +206 -0
- package/dist/client.js +301 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +137 -18
- package/dist/config.js +187 -106
- package/dist/config.js.map +1 -0
- package/dist/connect.d.ts +89 -0
- package/dist/connect.js +269 -0
- package/dist/connect.js.map +1 -0
- package/dist/doctor.d.ts +24 -0
- package/dist/doctor.js +120 -0
- package/dist/doctor.js.map +1 -0
- package/dist/events.d.ts +208 -0
- package/dist/events.js +454 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +121 -218
- package/dist/index.js.map +1 -0
- package/dist/instructions.d.ts +12 -0
- package/dist/instructions.js +114 -0
- package/dist/instructions.js.map +1 -0
- package/dist/mint.d.ts +62 -0
- package/dist/mint.js +135 -0
- package/dist/mint.js.map +1 -0
- package/dist/mirror.d.ts +68 -0
- package/dist/mirror.js +103 -0
- package/dist/mirror.js.map +1 -0
- package/dist/pane-queue.d.ts +29 -0
- package/dist/pane-queue.js +35 -0
- package/dist/pane-queue.js.map +1 -0
- package/dist/prompts.d.ts +79 -0
- package/dist/prompts.js +211 -0
- package/dist/prompts.js.map +1 -0
- package/dist/resolve.d.ts +72 -0
- package/dist/resolve.js +89 -0
- package/dist/resolve.js.map +1 -0
- package/dist/runtime.d.ts +54 -0
- package/dist/runtime.js +339 -0
- package/dist/runtime.js.map +1 -0
- package/dist/server.d.ts +56 -0
- package/dist/server.js +793 -0
- package/dist/server.js.map +1 -0
- package/dist/session-identifier.d.ts +48 -0
- package/dist/session-identifier.js +44 -0
- package/dist/session-identifier.js.map +1 -0
- package/dist/sessions-store.d.ts +38 -0
- package/dist/sessions-store.js +88 -0
- package/dist/sessions-store.js.map +1 -0
- package/dist/tmux.d.ts +200 -0
- package/dist/tmux.js +580 -0
- package/dist/tmux.js.map +1 -0
- package/hooks/metadata.mjs +99 -0
- package/hooks/permission-prompt.mjs +100 -0
- package/hooks/taskflow-hook.mjs +499 -0
- package/hooks/tool-logging.mjs +63 -0
- package/package.json +38 -29
- package/dist/agent-registry.d.ts +0 -28
- package/dist/agent-registry.js +0 -158
- package/dist/db.d.ts +0 -5
- package/dist/db.js +0 -220
- package/dist/helpers.d.ts +0 -21
- package/dist/helpers.js +0 -27
- package/dist/resources.d.ts +0 -2
- package/dist/resources.js +0 -89
- package/dist/retry.d.ts +0 -34
- package/dist/retry.js +0 -94
- package/dist/sse.d.ts +0 -10
- package/dist/sse.js +0 -824
- package/dist/tmux-bridge.d.ts +0 -13
- package/dist/tmux-bridge.js +0 -217
- package/dist/tools/activity.d.ts +0 -39
- package/dist/tools/activity.js +0 -152
- package/dist/tools/agent-inbox.d.ts +0 -12
- package/dist/tools/agent-inbox.js +0 -272
- package/dist/tools/agent.d.ts +0 -14
- package/dist/tools/agent.js +0 -168
- package/dist/tools/analytics.d.ts +0 -21
- package/dist/tools/analytics.js +0 -191
- package/dist/tools/checkpoint.d.ts +0 -27
- package/dist/tools/checkpoint.js +0 -105
- package/dist/tools/notifications.d.ts +0 -31
- package/dist/tools/notifications.js +0 -59
- package/dist/tools/projects.d.ts +0 -55
- package/dist/tools/projects.js +0 -112
- package/dist/tools/settings.d.ts +0 -19
- package/dist/tools/settings.js +0 -73
- package/dist/tools/tasks.d.ts +0 -105
- package/dist/tools/tasks.js +0 -403
- package/dist/tools/terminal.d.ts +0 -4
- package/dist/tools/terminal.js +0 -98
- package/dist/tools/timer.d.ts +0 -37
- package/dist/tools/timer.js +0 -154
- package/dist/types.d.ts +0 -83
- package/dist/types.js +0 -30
package/dist/server.js
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TaskFlow MCP server (stdio transport).
|
|
3
|
+
*
|
|
4
|
+
* Loads `.taskflow.json` once, then registers one tool per meaningful agent
|
|
5
|
+
* operation. Each tool validates its arguments with zod, resolves a profile
|
|
6
|
+
* (per-call `profile` arg > `TASKFLOW_PROFILE` > `default_profile` > `main`),
|
|
7
|
+
* builds a {@link TaskflowClient} for that profile, calls the backend, and
|
|
8
|
+
* returns a concise text/JSON result. Errors are returned as tool errors
|
|
9
|
+
* (`isError: true`) with the backend's detail — never thrown out of the tool.
|
|
10
|
+
*/
|
|
11
|
+
import { hostname } from "node:os";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { loadConfigFile, findConfigPath, resolveProfile, resolveProfileOrAsk, } from "./config.js";
|
|
16
|
+
import { TaskflowClient, TaskflowApiError } from "./client.js";
|
|
17
|
+
import { resolveAttachments } from "./attachments.js";
|
|
18
|
+
import { getMirrorStatus } from "./mirror.js";
|
|
19
|
+
import { downloadAttachment } from "./attachment-download.js";
|
|
20
|
+
import { detectTmuxPane } from "./tmux.js";
|
|
21
|
+
import { AGENT_INSTRUCTIONS } from "./instructions.js";
|
|
22
|
+
import { sessionIdentifier } from "./session-identifier.js";
|
|
23
|
+
import { getConnectionStatus } from "./connect.js";
|
|
24
|
+
import { selectProfile } from "./runtime.js";
|
|
25
|
+
import { readStickyProfile } from "./sessions-store.js";
|
|
26
|
+
/**
|
|
27
|
+
* The nudge attached to every check_messages result. A read cursor only advances
|
|
28
|
+
* when the agent says so, so the instruction has to travel WITH the messages —
|
|
29
|
+
* a model that reads them and moves on would be handed the same ones forever.
|
|
30
|
+
*/
|
|
31
|
+
function markReadReminder(count) {
|
|
32
|
+
return count > 0
|
|
33
|
+
? "You have unread messages above. When you have acted on them, call mark_read(channel, last_read_message=<highest id you handled>) so they stop being redelivered."
|
|
34
|
+
: "Nothing unread.";
|
|
35
|
+
}
|
|
36
|
+
/** Wrap a value as a successful text tool result (JSON-pretty for objects). */
|
|
37
|
+
function ok(value) {
|
|
38
|
+
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
39
|
+
return { content: [{ type: "text", text }] };
|
|
40
|
+
}
|
|
41
|
+
/** Wrap an error as a tool error result carrying the backend detail. */
|
|
42
|
+
function fail(err) {
|
|
43
|
+
const message = err instanceof TaskflowApiError
|
|
44
|
+
? err.message
|
|
45
|
+
: err instanceof Error
|
|
46
|
+
? err.message
|
|
47
|
+
: String(err);
|
|
48
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The refusal returned instead of guessing an identity.
|
|
52
|
+
*
|
|
53
|
+
* The server cannot prompt a human — MCP has no such primitive — but it can
|
|
54
|
+
* return a machine-readable refusal naming the exact follow-up call that
|
|
55
|
+
* resolves it. This is the typed-error equivalent for a protocol with no
|
|
56
|
+
* interaction primitive.
|
|
57
|
+
*/
|
|
58
|
+
export function ambiguityRefusal(profiles) {
|
|
59
|
+
return JSON.stringify({
|
|
60
|
+
error: "profile_ambiguous",
|
|
61
|
+
profiles,
|
|
62
|
+
hint: "This repo defines several agent identities and nothing says which one this terminal is. " +
|
|
63
|
+
"Ask your human which to use (show each display_name; 'recommended' is the file's default " +
|
|
64
|
+
"and 'in_use' means another terminal is already that agent), then call " +
|
|
65
|
+
"select_profile with their choice. Do NOT guess.",
|
|
66
|
+
}, null, 2);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The statuses a LIVE agent can report.
|
|
70
|
+
*
|
|
71
|
+
* The backend hands back a live agent's STORED status — `connected`, `idle` or
|
|
72
|
+
* `busy` (`effective_agent_status` in taskflow-agents' views.rs) — and only
|
|
73
|
+
* rewrites it to `offline` once the liveness window has lapsed. Testing for
|
|
74
|
+
* `connected` alone therefore read an actively-working terminal (which the
|
|
75
|
+
* instructions tell agents to report as `busy`) as FREE, and it self-healed
|
|
76
|
+
* within one heartbeat, making it an intermittent false negative.
|
|
77
|
+
*
|
|
78
|
+
* An allow-list, not `!offline`: `blocked` / `revoked` are administrative
|
|
79
|
+
* states that must not read as live, and neither should a status this build
|
|
80
|
+
* has never heard of.
|
|
81
|
+
*/
|
|
82
|
+
const LIVE_AGENT_STATUSES = new Set(["connected", "idle", "busy"]);
|
|
83
|
+
/** Whether a roster row describes an agent that is live right now. */
|
|
84
|
+
export function isLiveAgentStatus(status) {
|
|
85
|
+
return LIVE_AGENT_STATUSES.has(status);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Annotate each choice with whether that agent already has a live session, so
|
|
89
|
+
* the human can see which identity another terminal has taken.
|
|
90
|
+
*
|
|
91
|
+
* `agents` is null when liveness could not be determined; `in_use` is then
|
|
92
|
+
* omitted rather than guessed — a picker with names only is still usable, but a
|
|
93
|
+
* wrong `in_use` would send the human to the wrong terminal.
|
|
94
|
+
*/
|
|
95
|
+
export function markInUse(profiles, agents, agentIdByProfile) {
|
|
96
|
+
if (!agents)
|
|
97
|
+
return profiles;
|
|
98
|
+
const live = new Set(agents.filter((a) => isLiveAgentStatus(a.status)).map((a) => a.id));
|
|
99
|
+
return profiles.map((p) => ({ ...p, in_use: live.has(agentIdByProfile[p.name] ?? -1) }));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The warning returned when a human picks an identity another terminal already
|
|
103
|
+
* holds.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately NOT a refusal. A crashed terminal's session still looks live for
|
|
106
|
+
* the rest of the 90s window, so refusing would lock someone out of their own
|
|
107
|
+
* identity at the worst possible moment. The collision is real but recoverable;
|
|
108
|
+
* being unable to reconnect is neither.
|
|
109
|
+
*
|
|
110
|
+
* Returns undefined when there is nothing to warn about, so the caller can
|
|
111
|
+
* spread it into the result and have the field simply not appear.
|
|
112
|
+
*/
|
|
113
|
+
export function collisionWarning(profileName, live) {
|
|
114
|
+
if (!live)
|
|
115
|
+
return undefined;
|
|
116
|
+
const seen = live.last_seen_at ? ` (last seen ${live.last_seen_at})` : "";
|
|
117
|
+
return (`'${profileName}' already has a live session${seen}. Two terminals sharing one ` +
|
|
118
|
+
`identity share one inbox and one read cursor, so messages meant for one will ` +
|
|
119
|
+
`be marked read by the other. Tell your human before you continue.`);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Construct the MCP server with all tools registered. The config is loaded
|
|
123
|
+
* eagerly so a broken `.taskflow.json` fails fast at startup with a clear error.
|
|
124
|
+
*/
|
|
125
|
+
export function buildServer(options = {}) {
|
|
126
|
+
const configPath = findConfigPath({
|
|
127
|
+
configPath: options.configPath,
|
|
128
|
+
startDir: options.startDir,
|
|
129
|
+
env: options.env,
|
|
130
|
+
});
|
|
131
|
+
const config = loadConfigFile(configPath);
|
|
132
|
+
const env = options.env ?? process.env;
|
|
133
|
+
// Per-profile registered session id, so `heartbeat` / `capture_terminal`
|
|
134
|
+
// don't need the caller to thread a session id through every call.
|
|
135
|
+
const sessions = new Map();
|
|
136
|
+
const server = new McpServer({
|
|
137
|
+
name: "taskflow-mcp",
|
|
138
|
+
version: "0.1.0",
|
|
139
|
+
},
|
|
140
|
+
// Surfaced in the `initialize` result so the client shows the model how to
|
|
141
|
+
// use these tools on connect — the workflow and conventions the per-tool
|
|
142
|
+
// schemas can't convey (e.g. attach files, don't paste them inline).
|
|
143
|
+
{ instructions: AGENT_INSTRUCTIONS });
|
|
144
|
+
const paneOnce = detectTmuxPane().catch(() => null);
|
|
145
|
+
/**
|
|
146
|
+
* The project roster, fetched once for the life of the ambiguity.
|
|
147
|
+
*
|
|
148
|
+
* While a human has not picked, EVERY tool call is refused — and each refusal
|
|
149
|
+
* annotates the choices with liveness, which is a network round-trip against
|
|
150
|
+
* a 15s client timeout. Paying it per call, for as long as the ambiguity
|
|
151
|
+
* lasts, buys nothing: the answer cannot change without someone picking a
|
|
152
|
+
* profile, and picking one clears this cache.
|
|
153
|
+
*
|
|
154
|
+
* A FAILED fetch is not cached (the promise is dropped), so a backend that
|
|
155
|
+
* comes up later still gets asked.
|
|
156
|
+
*/
|
|
157
|
+
let roster;
|
|
158
|
+
const rosterForAmbiguity = () => {
|
|
159
|
+
// Liveness is a courtesy, never a blocker: any profile's credential can
|
|
160
|
+
// read the project roster, since all profiles in a file share a project.
|
|
161
|
+
const anyKey = Object.values(config.profiles)[0]?.key;
|
|
162
|
+
if (!anyKey)
|
|
163
|
+
return Promise.resolve(null);
|
|
164
|
+
return (roster ??= new TaskflowClient({ server: config.server, key: anyKey })
|
|
165
|
+
.listAgents()
|
|
166
|
+
.catch(() => {
|
|
167
|
+
roster = undefined;
|
|
168
|
+
return null;
|
|
169
|
+
}));
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Resolve the identity for one tool call. Returns a refusal instead of a
|
|
173
|
+
* client when a human still has to choose.
|
|
174
|
+
*/
|
|
175
|
+
const clientFor = async (profile) => {
|
|
176
|
+
const pane = await paneOnce;
|
|
177
|
+
const sticky = readStickyProfile({ configPath, pane });
|
|
178
|
+
const resolution = resolveProfileOrAsk(config, { profile, env, configPath, sticky });
|
|
179
|
+
if (resolution.kind === "ambiguous") {
|
|
180
|
+
const agents = await rosterForAmbiguity();
|
|
181
|
+
const byProfile = Object.fromEntries(Object.entries(config.profiles).map(([name, p]) => [name, p.agent_id]));
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
refusal: {
|
|
185
|
+
content: [
|
|
186
|
+
{
|
|
187
|
+
type: "text",
|
|
188
|
+
text: ambiguityRefusal(markInUse(resolution.profiles, agents, byProfile)),
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
isError: true,
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const resolved = resolution.profile;
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
resolved,
|
|
199
|
+
client: new TaskflowClient({ server: resolved.server, key: resolved.key }),
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
/** Ensure a live session exists for a profile; register one if not. */
|
|
203
|
+
const ensureSession = async (client, profile) => {
|
|
204
|
+
const { profileName } = profile;
|
|
205
|
+
// `connect.ts` registered one at startup; reuse it so this process owns ONE
|
|
206
|
+
// session row rather than racing its own connection — but ONLY when it is
|
|
207
|
+
// this profile's. The connection belongs to one identity; handing its
|
|
208
|
+
// session id to a call made as a different `profile:` sends another agent's
|
|
209
|
+
// session under this credential, and the backend's `load_owned_session`
|
|
210
|
+
// 403s it.
|
|
211
|
+
const connection = getConnectionStatus();
|
|
212
|
+
if (connection.session !== undefined && connection.profile === profileName) {
|
|
213
|
+
return connection.session;
|
|
214
|
+
}
|
|
215
|
+
const existing = sessions.get(profileName);
|
|
216
|
+
if (existing !== undefined)
|
|
217
|
+
return existing;
|
|
218
|
+
const session = await client.registerSession({
|
|
219
|
+
session_identifier: sessionIdentifier({
|
|
220
|
+
pane: await paneOnce,
|
|
221
|
+
profileName,
|
|
222
|
+
project: profile.project,
|
|
223
|
+
agentId: profile.agentId,
|
|
224
|
+
configPath: profile.configPath,
|
|
225
|
+
}),
|
|
226
|
+
host: hostname(),
|
|
227
|
+
pid: process.pid,
|
|
228
|
+
cwd: process.cwd(),
|
|
229
|
+
transport: "mcp",
|
|
230
|
+
});
|
|
231
|
+
sessions.set(profileName, session.id);
|
|
232
|
+
return session.id;
|
|
233
|
+
};
|
|
234
|
+
const profileArg = {
|
|
235
|
+
// Deliberately NOT `.min(1)` here, unlike `select_profile`: some clients
|
|
236
|
+
// send "" for an omitted optional string, and `resolveProfileOrAsk` already
|
|
237
|
+
// treats an empty value as ABSENT — which falls through to the per-terminal
|
|
238
|
+
// resolution and, when that is ambiguous, to the refusal. There is no
|
|
239
|
+
// silent guess on this path to protect against, only a needless hard error.
|
|
240
|
+
profile: z
|
|
241
|
+
.string()
|
|
242
|
+
.optional()
|
|
243
|
+
.describe("Which agent identity to act as. Normally omit it: the identity is resolved per terminal " +
|
|
244
|
+
"(TASKFLOW_PROFILE, this terminal's remembered pick, or the only profile in the file). " +
|
|
245
|
+
"In a repo with several identities and nothing saying which this terminal is, omitting it " +
|
|
246
|
+
"returns error 'profile_ambiguous' — ask your human, then call select_profile."),
|
|
247
|
+
};
|
|
248
|
+
// ---- identity / discovery ----
|
|
249
|
+
server.tool("whoami", "Confirm which TaskFlow agent identity and project this credential maps to, plus the connection and terminal-mirror state. Connection and heartbeat are automatic — this confirms them, it does not establish them. mirror.state 'off' just means there is no tmux pane to stream; it is not an error.", { ...profileArg }, async ({ profile }) => {
|
|
250
|
+
try {
|
|
251
|
+
const picked = await clientFor(profile);
|
|
252
|
+
if (!picked.ok)
|
|
253
|
+
return picked.refusal;
|
|
254
|
+
const { client } = picked;
|
|
255
|
+
const identity = await client.whoami();
|
|
256
|
+
// The mirror's health rides along with identity because a dead mirror
|
|
257
|
+
// is otherwise invisible: it only ever wrote one line to stderr, which
|
|
258
|
+
// nobody reads, so "the dashboard terminal is stale" could only be
|
|
259
|
+
// answered by inspecting /proc and open sockets.
|
|
260
|
+
return ok({
|
|
261
|
+
...identity,
|
|
262
|
+
connection: getConnectionStatus(),
|
|
263
|
+
mirror: getMirrorStatus(),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
return fail(err);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
server.tool("select_profile", "Choose which agent identity this terminal is, when the repo defines several. Call this ONLY after asking your human which one to use — never guess. The choice is remembered for this terminal, so you will not be asked again after a reconnect.", {
|
|
271
|
+
// `.trim().min(1)`: `chooseProfileName` treats an empty string as ABSENT
|
|
272
|
+
// and falls back to `default_profile ?? "main"`, so `profile: ""` (or,
|
|
273
|
+
// without the trim, whitespace like " ") would silently select and
|
|
274
|
+
// stickie the default identity — the exact silent guess this tool
|
|
275
|
+
// exists to remove. zod trims before the length check, so the trimmed
|
|
276
|
+
// value is what reaches the handler.
|
|
277
|
+
profile: z
|
|
278
|
+
.string()
|
|
279
|
+
.trim()
|
|
280
|
+
.min(1)
|
|
281
|
+
.describe("The profile name your human chose, e.g. 'main' or 'bear'."),
|
|
282
|
+
}, async ({ profile }) => {
|
|
283
|
+
try {
|
|
284
|
+
// Throws with the available names if this one is not in the file.
|
|
285
|
+
const resolved = resolveProfile(config, { profile, env, configPath });
|
|
286
|
+
// BEFORE connecting, not after: `selectProfile` registers a session for
|
|
287
|
+
// this very agent, so a lookup afterwards would find OUR OWN row and
|
|
288
|
+
// warn about a collision with ourselves on every single call.
|
|
289
|
+
const live = await new TaskflowClient({ server: resolved.server, key: resolved.key })
|
|
290
|
+
.listAgents()
|
|
291
|
+
.then((agents) => agents.find((a) => a.id === resolved.agentId && isLiveAgentStatus(a.status)) ?? null)
|
|
292
|
+
.catch(() => null);
|
|
293
|
+
await selectProfile(resolved);
|
|
294
|
+
// The pick is made, so the ambiguity is over: drop the cached roster
|
|
295
|
+
// rather than let a stale one outlive what it was fetched for.
|
|
296
|
+
roster = undefined;
|
|
297
|
+
const warning = collisionWarning(resolved.profileName, live);
|
|
298
|
+
const connection = getConnectionStatus();
|
|
299
|
+
return ok({
|
|
300
|
+
selected: resolved.profileName,
|
|
301
|
+
display_name: resolved.displayName,
|
|
302
|
+
agent_id: resolved.agentId,
|
|
303
|
+
project: resolved.project,
|
|
304
|
+
connection,
|
|
305
|
+
...(warning ? { warning } : {}),
|
|
306
|
+
// Reports what the connection actually is. `selectProfile` starts the
|
|
307
|
+
// connection without awaiting it — deliberately, since `settled`
|
|
308
|
+
// stays pending for as long as the backend is down — so an
|
|
309
|
+
// unconditional "Connected." asserted a success nothing observed,
|
|
310
|
+
// sitting right beside a `connection.state` of `starting` or a
|
|
311
|
+
// `retrying` that may never end.
|
|
312
|
+
note: connection.state === "active"
|
|
313
|
+
? "Connected. This terminal will use this identity from now on."
|
|
314
|
+
: `Selected; the connection is ${connection.state}${connection.detail ? ` (${connection.detail})` : ""}. This terminal will use this identity from now on — call whoami to check the connection.`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
return fail(err);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
server.tool("list_tasks", "List tasks in the agent's project. Optional filters: status (e.g. not_started, in_progress, partial_done, done) and assigned='me' for tasks this agent has claimed.", {
|
|
322
|
+
status: z.string().optional().describe("Filter by task status string."),
|
|
323
|
+
assigned: z.string().optional().describe("'me' to show only tasks claimed by this agent."),
|
|
324
|
+
...profileArg,
|
|
325
|
+
}, async ({ status, assigned, profile }) => {
|
|
326
|
+
try {
|
|
327
|
+
const picked = await clientFor(profile);
|
|
328
|
+
if (!picked.ok)
|
|
329
|
+
return picked.refusal;
|
|
330
|
+
const { client } = picked;
|
|
331
|
+
return ok(await client.listTasks({ status, assigned }));
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
return fail(err);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
server.tool("list_channels", "List the chat channels this agent can see in its project (shared rooms plus any DMs it is on the roster of).", { ...profileArg }, async ({ profile }) => {
|
|
338
|
+
try {
|
|
339
|
+
const picked = await clientFor(profile);
|
|
340
|
+
if (!picked.ok)
|
|
341
|
+
return picked.refusal;
|
|
342
|
+
const { client } = picked;
|
|
343
|
+
return ok(await client.listChannels());
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
return fail(err);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
server.tool("list_agents", "List the other agents in this project so you know who to address.", { ...profileArg }, async ({ profile }) => {
|
|
350
|
+
try {
|
|
351
|
+
const picked = await clientFor(profile);
|
|
352
|
+
if (!picked.ok)
|
|
353
|
+
return picked.refusal;
|
|
354
|
+
const { client } = picked;
|
|
355
|
+
return ok(await client.listAgents());
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
return fail(err);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
// ---- tasks ----
|
|
362
|
+
server.tool("create_task", "Create a task in the agent's project. Optionally self-claim it. Returns the created task.", {
|
|
363
|
+
title: z.string().min(1).describe("Short task title."),
|
|
364
|
+
description: z.string().optional().describe("Markdown description."),
|
|
365
|
+
priority: z
|
|
366
|
+
.enum(["low", "normal", "high", "critical"])
|
|
367
|
+
.optional()
|
|
368
|
+
.describe("Task priority (default: normal)."),
|
|
369
|
+
notes: z.string().optional().describe("Markdown notes."),
|
|
370
|
+
claim: z.boolean().optional().describe("If true, assign the new task to this agent."),
|
|
371
|
+
files: z
|
|
372
|
+
.array(z.string())
|
|
373
|
+
.optional()
|
|
374
|
+
.describe("Paths to attach to the new task, relative to the project root (or absolute, inside it). Max 25MB each."),
|
|
375
|
+
...profileArg,
|
|
376
|
+
}, async ({ title, description, notes, priority, claim, files, profile }) => {
|
|
377
|
+
try {
|
|
378
|
+
const picked = await clientFor(profile);
|
|
379
|
+
if (!picked.ok)
|
|
380
|
+
return picked.refusal;
|
|
381
|
+
const { client } = picked;
|
|
382
|
+
const task = (await client.createTask({
|
|
383
|
+
title,
|
|
384
|
+
description_markdown: description,
|
|
385
|
+
notes_markdown: notes,
|
|
386
|
+
priority,
|
|
387
|
+
claim,
|
|
388
|
+
}));
|
|
389
|
+
if (!files?.length)
|
|
390
|
+
return ok(task);
|
|
391
|
+
// Attachments can only be hung on a task that exists, so this is a
|
|
392
|
+
// second call. If it fails the task is ALREADY created — reporting a
|
|
393
|
+
// plain error would send the caller off to create a duplicate, so say
|
|
394
|
+
// what happened and hand back the id it can retry against.
|
|
395
|
+
// A malformed create response must not become a confusing 404 from
|
|
396
|
+
// `/agents/tasks/undefined/attachments`.
|
|
397
|
+
if (typeof task.id !== "number") {
|
|
398
|
+
return ok({
|
|
399
|
+
...task,
|
|
400
|
+
attachments: [],
|
|
401
|
+
warning: "The task was created but the server did not return its id, so the files could not be attached. Find the task and attach them to it.",
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
const attachments = await resolveAttachments(files, dirname(configPath));
|
|
406
|
+
const uploaded = (await client.uploadTaskAttachments(task.id, attachments));
|
|
407
|
+
return ok({ ...task, attachments: uploaded?.attachments ?? [] });
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
return ok({
|
|
411
|
+
...task,
|
|
412
|
+
attachments: [],
|
|
413
|
+
warning: `The task was created (id ${task.id}) but attaching files failed: ${err instanceof Error ? err.message : String(err)}. Do NOT create the task again — retry the upload against this id.`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
return fail(err);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
server.tool("update_task", "Edit a task's content in the agent's project: title, description, notes, priority, and/or attach files. Only the fields you pass are changed — anything you omit is left as it is. Use update_task_status to move a task's status, and claim_task to assign it. Returns the updated task.", {
|
|
422
|
+
task: z.number().int().describe("Task id."),
|
|
423
|
+
title: z.string().min(1).optional().describe("New title."),
|
|
424
|
+
description: z.string().optional().describe("New markdown description (replaces)."),
|
|
425
|
+
notes: z.string().optional().describe("New markdown notes (replaces)."),
|
|
426
|
+
priority: z
|
|
427
|
+
.enum(["low", "normal", "high", "critical"])
|
|
428
|
+
.optional()
|
|
429
|
+
.describe("New task priority."),
|
|
430
|
+
files: z
|
|
431
|
+
.array(z.string())
|
|
432
|
+
.optional()
|
|
433
|
+
.describe("Paths to attach to this task, relative to the project root (or absolute, inside it). Max 25MB each. Attaching is additive — it never removes existing attachments."),
|
|
434
|
+
...profileArg,
|
|
435
|
+
}, async ({ task, title, description, notes, priority, files, profile }) => {
|
|
436
|
+
try {
|
|
437
|
+
const picked = await clientFor(profile);
|
|
438
|
+
if (!picked.ok)
|
|
439
|
+
return picked.refusal;
|
|
440
|
+
const { client } = picked;
|
|
441
|
+
// Nothing to do is a caller mistake worth naming: a no-op that returns
|
|
442
|
+
// the task unchanged reads as success and hides the missing argument.
|
|
443
|
+
const hasFields = title !== undefined ||
|
|
444
|
+
description !== undefined ||
|
|
445
|
+
notes !== undefined ||
|
|
446
|
+
priority !== undefined;
|
|
447
|
+
if (!hasFields && !files?.length) {
|
|
448
|
+
return fail(new Error("Nothing to update: pass at least one of title, description, notes, priority or files."));
|
|
449
|
+
}
|
|
450
|
+
let updated = undefined;
|
|
451
|
+
if (hasFields) {
|
|
452
|
+
updated = await client.updateTask(task, {
|
|
453
|
+
title,
|
|
454
|
+
description_markdown: description,
|
|
455
|
+
notes_markdown: notes,
|
|
456
|
+
priority,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if (!files?.length)
|
|
460
|
+
return ok(updated);
|
|
461
|
+
const base = typeof updated === "object" && updated !== null ? updated : { id: task };
|
|
462
|
+
// Same shape as `create_task`: the FIELDS are already written by the
|
|
463
|
+
// time an upload can fail, so reporting a bare error would tell the
|
|
464
|
+
// caller their edit did not land and invite them to send it again.
|
|
465
|
+
// `uploadTaskAttachments` carries no client_nonce, so a blind retry
|
|
466
|
+
// duplicates the attachments.
|
|
467
|
+
try {
|
|
468
|
+
const attachments = await resolveAttachments(files, dirname(configPath));
|
|
469
|
+
const uploaded = (await client.uploadTaskAttachments(task, attachments));
|
|
470
|
+
return ok({ ...base, attachments: uploaded?.attachments ?? [] });
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
return ok({
|
|
474
|
+
...base,
|
|
475
|
+
attachments: [],
|
|
476
|
+
warning: `${hasFields ? "The edit was applied" : "Nothing was changed"} but attaching files failed: ${err instanceof Error ? err.message : String(err)}. Do NOT re-send the fields — retry only the upload.`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
return fail(err);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
server.tool("update_task_status", "Advance a task's status (e.g. to partial_done to request review, or in_progress). Returns the updated task.", {
|
|
485
|
+
task: z.number().int().describe("Task id."),
|
|
486
|
+
// An enum, not a free string: the backend rejects anything outside this set
|
|
487
|
+
// with a 422 the agent only discovers at call time. Plausible-sounding
|
|
488
|
+
// guesses ("in_review", "todo") are exactly what a model reaches for, so
|
|
489
|
+
// the valid set belongs in the schema where it can't be guessed wrong.
|
|
490
|
+
status: z
|
|
491
|
+
.enum([
|
|
492
|
+
"not_started",
|
|
493
|
+
"in_progress",
|
|
494
|
+
"paused",
|
|
495
|
+
"blocked",
|
|
496
|
+
"partial_done",
|
|
497
|
+
"done",
|
|
498
|
+
"archived",
|
|
499
|
+
])
|
|
500
|
+
.describe("New status. Use partial_done to request review."),
|
|
501
|
+
...profileArg,
|
|
502
|
+
}, async ({ task, status, profile }) => {
|
|
503
|
+
try {
|
|
504
|
+
const picked = await clientFor(profile);
|
|
505
|
+
if (!picked.ok)
|
|
506
|
+
return picked.refusal;
|
|
507
|
+
const { client } = picked;
|
|
508
|
+
return ok(await client.updateTaskStatus(task, status));
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
return fail(err);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
server.tool("claim_task", "Self-assign a task in this agent's project. Returns the updated task.", { task: z.number().int().describe("Task id."), ...profileArg }, async ({ task, profile }) => {
|
|
515
|
+
try {
|
|
516
|
+
const picked = await clientFor(profile);
|
|
517
|
+
if (!picked.ok)
|
|
518
|
+
return picked.refusal;
|
|
519
|
+
const { client } = picked;
|
|
520
|
+
return ok(await client.claimTask(task));
|
|
521
|
+
}
|
|
522
|
+
catch (err) {
|
|
523
|
+
return fail(err);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
server.tool("report_review", "Record a review verdict on a task (decision: approved | changes_requested). Reports back to the assigned agent and transitions the task. Consider the 'reviewer' profile for review work.", {
|
|
527
|
+
task: z.number().int().describe("Task id under review."),
|
|
528
|
+
decision: z.enum(["approved", "changes_requested"]).describe("The review verdict."),
|
|
529
|
+
body: z.string().optional().describe("Optional review note (markdown)."),
|
|
530
|
+
...profileArg,
|
|
531
|
+
}, async ({ task, decision, body, profile }) => {
|
|
532
|
+
try {
|
|
533
|
+
const picked = await clientFor(profile);
|
|
534
|
+
if (!picked.ok)
|
|
535
|
+
return picked.refusal;
|
|
536
|
+
const { client } = picked;
|
|
537
|
+
return ok(await client.reportReview(task, decision, body));
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
return fail(err);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
// ---- messaging ----
|
|
544
|
+
server.tool("send_message", "Send a chat message as this agent into a channel. Use list_channels to find channel ids.", {
|
|
545
|
+
channel: z.number().int().describe("Channel id to post in."),
|
|
546
|
+
body: z.string().min(1).describe("Message body (markdown)."),
|
|
547
|
+
priority: z
|
|
548
|
+
.enum(["normal", "important", "urgent"])
|
|
549
|
+
.optional()
|
|
550
|
+
.describe("Message priority (default: normal)."),
|
|
551
|
+
files: z
|
|
552
|
+
.array(z.string())
|
|
553
|
+
.optional()
|
|
554
|
+
.describe("Paths to attach, relative to the project root (or absolute, inside it). Max 25MB each."),
|
|
555
|
+
...profileArg,
|
|
556
|
+
}, async ({ channel, body, priority, files, profile }) => {
|
|
557
|
+
try {
|
|
558
|
+
const picked = await clientFor(profile);
|
|
559
|
+
if (!picked.ok)
|
|
560
|
+
return picked.refusal;
|
|
561
|
+
const { client } = picked;
|
|
562
|
+
const attachments = files?.length
|
|
563
|
+
? await resolveAttachments(files, dirname(configPath))
|
|
564
|
+
: undefined;
|
|
565
|
+
return ok(await client.sendMessage({
|
|
566
|
+
channel,
|
|
567
|
+
body_markdown: body,
|
|
568
|
+
priority,
|
|
569
|
+
attachments,
|
|
570
|
+
}));
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
return fail(err);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
server.tool("download_attachment", "Download a message attachment to disk and return its path. Get the `url` from an attachment on a message returned by check_messages. Returns a PATH, not the file's contents — open it with your own file-reading tool. Attachments are files in general: text and PDFs can be read directly, archives should be listed rather than read, and large files should be inspected in parts. Check `size_bytes` before reading anything wholesale.", {
|
|
577
|
+
url: z
|
|
578
|
+
.string()
|
|
579
|
+
.min(1)
|
|
580
|
+
.describe("The attachment's `url` from check_messages, e.g. /media/<key>."),
|
|
581
|
+
name: z
|
|
582
|
+
.string()
|
|
583
|
+
.optional()
|
|
584
|
+
.describe("Optional friendlier filename. Directory parts are stripped."),
|
|
585
|
+
...profileArg,
|
|
586
|
+
}, async ({ url, name, profile }) => {
|
|
587
|
+
try {
|
|
588
|
+
const picked = await clientFor(profile);
|
|
589
|
+
if (!picked.ok)
|
|
590
|
+
return picked.refusal;
|
|
591
|
+
const { resolved } = picked;
|
|
592
|
+
return ok(await downloadAttachment({
|
|
593
|
+
url,
|
|
594
|
+
name,
|
|
595
|
+
server: resolved.server,
|
|
596
|
+
key: resolved.key,
|
|
597
|
+
root: dirname(configPath),
|
|
598
|
+
}));
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
return fail(err);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
server.tool("check_messages", "Check for messages addressed to you. Returns only what you have NOT yet marked read, across every channel you're in. " +
|
|
605
|
+
"IMPORTANT: after you have read and acted on the messages, call mark_read for each channel with the highest message id you handled — " +
|
|
606
|
+
"otherwise they stay unread and you will be handed the same messages again on your next check. " +
|
|
607
|
+
"Pass unread_only=false to re-read history you have already marked read.", {
|
|
608
|
+
// Optional on purpose: an agent polling for new work has no channel id to
|
|
609
|
+
// start from, and requiring one made "do I have any messages?"
|
|
610
|
+
// unanswerable without first guessing an id.
|
|
611
|
+
channel: z
|
|
612
|
+
.number()
|
|
613
|
+
.int()
|
|
614
|
+
.optional()
|
|
615
|
+
.describe("Channel id to read. Omit to check all channels you're in."),
|
|
616
|
+
// Unread-by-default is the whole point: "what do I still owe a response
|
|
617
|
+
// to?" is the question an agent actually has, and answering it
|
|
618
|
+
// server-side means the agent cannot get it wrong by mis-comparing ids.
|
|
619
|
+
unread_only: z
|
|
620
|
+
.boolean()
|
|
621
|
+
.optional()
|
|
622
|
+
.describe("Default true — only messages past your read cursor. false returns full history."),
|
|
623
|
+
since: z.number().int().optional().describe("Only return messages with id greater than this."),
|
|
624
|
+
limit: z.number().int().optional().describe("Max messages (default 50, max 200)."),
|
|
625
|
+
...profileArg,
|
|
626
|
+
}, async ({ channel, unread_only, since, limit, profile }) => {
|
|
627
|
+
try {
|
|
628
|
+
const picked = await clientFor(profile);
|
|
629
|
+
if (!picked.ok)
|
|
630
|
+
return picked.refusal;
|
|
631
|
+
const { client } = picked;
|
|
632
|
+
const unread = unread_only !== false;
|
|
633
|
+
if (channel !== undefined) {
|
|
634
|
+
const page = await client.listMessages({ channel, since, limit, unread });
|
|
635
|
+
return ok({ ...page, reminder: markReadReminder(page.messages.length) });
|
|
636
|
+
}
|
|
637
|
+
// Fan out across the roster. Channels are few (a project room plus a
|
|
638
|
+
// handful of DMs), so this stays one small burst rather than a paged
|
|
639
|
+
// crawl. A per-channel failure must not sink the whole poll.
|
|
640
|
+
const channels = await client.listChannels();
|
|
641
|
+
const pages = await Promise.all(channels.map(async (c) => {
|
|
642
|
+
try {
|
|
643
|
+
const page = await client.listMessages({ channel: c.id, since, limit, unread });
|
|
644
|
+
return { channel: c.id, title: c.title, ...page };
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
return { channel: c.id, title: c.title, error: err.message };
|
|
648
|
+
}
|
|
649
|
+
}));
|
|
650
|
+
// Drop empty channels when polling for unread: a wall of "0 messages"
|
|
651
|
+
// buries the one channel that actually needs attention.
|
|
652
|
+
const withMessages = unread
|
|
653
|
+
? pages.filter((p) => !("messages" in p) || p.messages.length > 0)
|
|
654
|
+
: pages;
|
|
655
|
+
const total = withMessages.reduce((n, p) => n + (("messages" in p ? p.messages.length : 0)), 0);
|
|
656
|
+
return ok({ channels: withMessages, total_unread: total, reminder: markReadReminder(total) });
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
return fail(err);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
server.tool("mark_read", "Mark how far this agent has read in a channel (advance the read cursor forward).", {
|
|
663
|
+
channel: z.number().int().describe("Channel id."),
|
|
664
|
+
last_read_message: z.number().int().describe("The furthest message id now read."),
|
|
665
|
+
...profileArg,
|
|
666
|
+
}, async ({ channel, last_read_message, profile }) => {
|
|
667
|
+
try {
|
|
668
|
+
const picked = await clientFor(profile);
|
|
669
|
+
if (!picked.ok)
|
|
670
|
+
return picked.refusal;
|
|
671
|
+
const { client } = picked;
|
|
672
|
+
return ok(await client.markRead(channel, last_read_message));
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
return fail(err);
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
// ---- sessions / terminal / activity ----
|
|
679
|
+
server.tool("register_session", "Register (or reconnect) a live session so humans see this agent online. Defaults the identifier to host:pid and cwd to the current directory.", {
|
|
680
|
+
session_identifier: z.string().optional().describe("Stable session id (default host:pid)."),
|
|
681
|
+
cwd: z.string().optional().describe("Working directory (default process.cwd())."),
|
|
682
|
+
...profileArg,
|
|
683
|
+
}, async ({ session_identifier, cwd, profile }) => {
|
|
684
|
+
try {
|
|
685
|
+
const picked = await clientFor(profile);
|
|
686
|
+
if (!picked.ok)
|
|
687
|
+
return picked.refusal;
|
|
688
|
+
const { client, resolved } = picked;
|
|
689
|
+
const session = await client.registerSession({
|
|
690
|
+
// Prefer the tmux pane, exactly as `ensureSession` and the mirror do.
|
|
691
|
+
// Calling this with no argument always produced `host:pid`, so an
|
|
692
|
+
// agent running under tmux ended up with TWO session rows — one from
|
|
693
|
+
// this tool, one from the mirror's `tmux:<host>:<pane>` — which is
|
|
694
|
+
// the duplication the mirror's own comment says it is avoiding.
|
|
695
|
+
session_identifier: session_identifier?.trim() ||
|
|
696
|
+
sessionIdentifier({
|
|
697
|
+
pane: await detectTmuxPane(),
|
|
698
|
+
profileName: resolved.profileName,
|
|
699
|
+
project: resolved.project,
|
|
700
|
+
agentId: resolved.agentId,
|
|
701
|
+
configPath: resolved.configPath,
|
|
702
|
+
}),
|
|
703
|
+
host: hostname(),
|
|
704
|
+
pid: process.pid,
|
|
705
|
+
cwd: cwd ?? process.cwd(),
|
|
706
|
+
transport: "mcp",
|
|
707
|
+
});
|
|
708
|
+
sessions.set(resolved.profileName, session.id);
|
|
709
|
+
return ok(session);
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
return fail(err);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
server.tool("heartbeat", "Send a liveness heartbeat for the current session (auto-registers one if needed). Optional status hint: idle | busy.", {
|
|
716
|
+
status: z.enum(["idle", "busy", "connected"]).optional().describe("Activity hint."),
|
|
717
|
+
...profileArg,
|
|
718
|
+
}, async ({ status, profile }) => {
|
|
719
|
+
try {
|
|
720
|
+
const picked = await clientFor(profile);
|
|
721
|
+
if (!picked.ok)
|
|
722
|
+
return picked.refusal;
|
|
723
|
+
const { client, resolved } = picked;
|
|
724
|
+
const sessionId = await ensureSession(client, resolved);
|
|
725
|
+
return ok(await client.heartbeat(sessionId, status));
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
return fail(err);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
server.tool("capture_terminal", "Stream a chunk of terminal output into the current session (auto-registers one if needed). Humans can watch it live.", {
|
|
732
|
+
content: z.string().min(1).describe("Terminal text to append."),
|
|
733
|
+
// All four backend variants: the UI colours stdin and system distinctly
|
|
734
|
+
// (prompt green / dimmed italic), so narrowing this to stdout|stderr made
|
|
735
|
+
// those styles unreachable through the only supported write path.
|
|
736
|
+
stream: z
|
|
737
|
+
.enum(["stdout", "stderr", "stdin", "system"])
|
|
738
|
+
.optional()
|
|
739
|
+
.describe("Which stream (default stdout). stdin echoes a command, system marks session notices."),
|
|
740
|
+
...profileArg,
|
|
741
|
+
}, async ({ content, stream, profile }) => {
|
|
742
|
+
try {
|
|
743
|
+
const picked = await clientFor(profile);
|
|
744
|
+
if (!picked.ok)
|
|
745
|
+
return picked.refusal;
|
|
746
|
+
const { client, resolved } = picked;
|
|
747
|
+
const sessionId = await ensureSession(client, resolved);
|
|
748
|
+
return ok(await client.appendFrame(sessionId, { content, stream }));
|
|
749
|
+
}
|
|
750
|
+
catch (err) {
|
|
751
|
+
return fail(err);
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
server.tool("log_activity", "Log a real activity event (e.g. an action you took). Optionally attach it to a task.", {
|
|
755
|
+
action: z.string().min(1).describe("Short verb (e.g. Read, Edit, Bash, note)."),
|
|
756
|
+
body: z.string().optional().describe("Optional detail (markdown)."),
|
|
757
|
+
task: z.number().int().optional().describe("Optional task id to link."),
|
|
758
|
+
post_to_github: z
|
|
759
|
+
.boolean()
|
|
760
|
+
.optional()
|
|
761
|
+
.describe("Also post this event as a comment on the task's linked GitHub issue, under your owner's identity. Requires `task`. Best-effort: only posts when the project is GitHub-linked, the task is published as an issue, and your owner is connected and opted in (post_as_me) — otherwise it silently no-ops and the activity is still recorded."),
|
|
762
|
+
...profileArg,
|
|
763
|
+
}, async ({ action, body, task, post_to_github, profile }) => {
|
|
764
|
+
try {
|
|
765
|
+
const picked = await clientFor(profile);
|
|
766
|
+
if (!picked.ok)
|
|
767
|
+
return picked.refusal;
|
|
768
|
+
const { client } = picked;
|
|
769
|
+
return ok(await client.logActivity({ action, body_markdown: body, task, post_to_github }));
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
return fail(err);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
server.tool("get_activity", "Read recent activity in the project (newest first). Optional task filter and limit.", {
|
|
776
|
+
task: z.number().int().optional().describe("Filter to one task's activity."),
|
|
777
|
+
limit: z.number().int().optional().describe("Max events (default 50, max 200)."),
|
|
778
|
+
...profileArg,
|
|
779
|
+
}, async ({ task, limit, profile }) => {
|
|
780
|
+
try {
|
|
781
|
+
const picked = await clientFor(profile);
|
|
782
|
+
if (!picked.ok)
|
|
783
|
+
return picked.refusal;
|
|
784
|
+
const { client } = picked;
|
|
785
|
+
return ok(await client.listActivity({ task, limit }));
|
|
786
|
+
}
|
|
787
|
+
catch (err) {
|
|
788
|
+
return fail(err);
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
return server;
|
|
792
|
+
}
|
|
793
|
+
//# sourceMappingURL=server.js.map
|