@dalmasonto/taskflow-mcp 1.0.33 → 2.0.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.
Files changed (99) hide show
  1. package/README.md +138 -193
  2. package/dist/attachment-download.d.ts +74 -0
  3. package/dist/attachment-download.js +193 -0
  4. package/dist/attachment-download.js.map +1 -0
  5. package/dist/attachments.d.ts +23 -0
  6. package/dist/attachments.js +66 -0
  7. package/dist/attachments.js.map +1 -0
  8. package/dist/client.d.ts +206 -0
  9. package/dist/client.js +279 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +137 -18
  12. package/dist/config.js +187 -106
  13. package/dist/config.js.map +1 -0
  14. package/dist/connect.d.ts +89 -0
  15. package/dist/connect.js +269 -0
  16. package/dist/connect.js.map +1 -0
  17. package/dist/doctor.d.ts +24 -0
  18. package/dist/doctor.js +120 -0
  19. package/dist/doctor.js.map +1 -0
  20. package/dist/events.d.ts +186 -0
  21. package/dist/events.js +407 -0
  22. package/dist/events.js.map +1 -0
  23. package/dist/index.d.ts +7 -1
  24. package/dist/index.js +124 -184
  25. package/dist/index.js.map +1 -0
  26. package/dist/instructions.d.ts +12 -0
  27. package/dist/instructions.js +114 -0
  28. package/dist/instructions.js.map +1 -0
  29. package/dist/mint.d.ts +62 -0
  30. package/dist/mint.js +135 -0
  31. package/dist/mint.js.map +1 -0
  32. package/dist/mirror.d.ts +68 -0
  33. package/dist/mirror.js +103 -0
  34. package/dist/mirror.js.map +1 -0
  35. package/dist/pane-queue.d.ts +29 -0
  36. package/dist/pane-queue.js +35 -0
  37. package/dist/pane-queue.js.map +1 -0
  38. package/dist/prompts.d.ts +79 -0
  39. package/dist/prompts.js +211 -0
  40. package/dist/prompts.js.map +1 -0
  41. package/dist/resolve.d.ts +72 -0
  42. package/dist/resolve.js +89 -0
  43. package/dist/resolve.js.map +1 -0
  44. package/dist/runtime.d.ts +54 -0
  45. package/dist/runtime.js +321 -0
  46. package/dist/runtime.js.map +1 -0
  47. package/dist/server.d.ts +56 -0
  48. package/dist/server.js +793 -0
  49. package/dist/server.js.map +1 -0
  50. package/dist/session-identifier.d.ts +48 -0
  51. package/dist/session-identifier.js +44 -0
  52. package/dist/session-identifier.js.map +1 -0
  53. package/dist/sessions-store.d.ts +38 -0
  54. package/dist/sessions-store.js +88 -0
  55. package/dist/sessions-store.js.map +1 -0
  56. package/dist/tmux.d.ts +200 -0
  57. package/dist/tmux.js +580 -0
  58. package/dist/tmux.js.map +1 -0
  59. package/hooks/metadata.mjs +99 -0
  60. package/hooks/permission-prompt.mjs +100 -0
  61. package/hooks/taskflow-hook.mjs +499 -0
  62. package/hooks/tool-logging.mjs +63 -0
  63. package/package.json +38 -29
  64. package/dist/agent-registry.d.ts +0 -27
  65. package/dist/agent-registry.js +0 -153
  66. package/dist/db.d.ts +0 -5
  67. package/dist/db.js +0 -220
  68. package/dist/helpers.d.ts +0 -21
  69. package/dist/helpers.js +0 -27
  70. package/dist/resources.d.ts +0 -2
  71. package/dist/resources.js +0 -89
  72. package/dist/sse.d.ts +0 -10
  73. package/dist/sse.js +0 -766
  74. package/dist/tmux-bridge.d.ts +0 -12
  75. package/dist/tmux-bridge.js +0 -157
  76. package/dist/tools/activity.d.ts +0 -39
  77. package/dist/tools/activity.js +0 -152
  78. package/dist/tools/agent-inbox.d.ts +0 -12
  79. package/dist/tools/agent-inbox.js +0 -257
  80. package/dist/tools/agent.d.ts +0 -14
  81. package/dist/tools/agent.js +0 -168
  82. package/dist/tools/analytics.d.ts +0 -21
  83. package/dist/tools/analytics.js +0 -191
  84. package/dist/tools/checkpoint.d.ts +0 -27
  85. package/dist/tools/checkpoint.js +0 -105
  86. package/dist/tools/notifications.d.ts +0 -31
  87. package/dist/tools/notifications.js +0 -59
  88. package/dist/tools/projects.d.ts +0 -55
  89. package/dist/tools/projects.js +0 -112
  90. package/dist/tools/settings.d.ts +0 -19
  91. package/dist/tools/settings.js +0 -73
  92. package/dist/tools/tasks.d.ts +0 -105
  93. package/dist/tools/tasks.js +0 -403
  94. package/dist/tools/terminal.d.ts +0 -4
  95. package/dist/tools/terminal.js +0 -98
  96. package/dist/tools/timer.d.ts +0 -37
  97. package/dist/tools/timer.js +0 -154
  98. package/dist/types.d.ts +0 -82
  99. 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