@slock-ai/daemon 0.40.1 → 0.41.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.
@@ -2,12 +2,137 @@
2
2
  import {
3
3
  buildFetchDispatcher,
4
4
  logger
5
- } from "./chunk-E6OOH3IC.js";
5
+ } from "./chunk-JG7ONJZ6.js";
6
6
 
7
7
  // src/chat-bridge.ts
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { z as z2 } from "zod";
11
+
12
+ // src/deprecatedMcpShim.ts
10
13
  import { z } from "zod";
14
+ var DEPRECATED_MCP_SHIM_HEADER = "This MCP tool is deprecated. Use slock CLI instead.";
15
+ var sendMessageDeprecatedSchema = {
16
+ target: z.string().describe("Deprecated target argument."),
17
+ content: z.string().optional().describe("Deprecated content argument."),
18
+ attachment_ids: z.array(z.string()).optional().describe("Deprecated attachment ids.")
19
+ };
20
+ var checkMessagesDeprecatedSchema = {};
21
+ var readHistoryDeprecatedSchema = {
22
+ channel: z.string().describe("Deprecated channel argument."),
23
+ limit: z.number().optional().describe("Deprecated limit argument."),
24
+ around: z.union([z.string(), z.number()]).optional().describe("Deprecated around argument."),
25
+ before: z.number().optional().describe("Deprecated before argument."),
26
+ after: z.number().optional().describe("Deprecated after argument.")
27
+ };
28
+ var searchMessagesDeprecatedSchema = {
29
+ query: z.string().describe("Deprecated query argument."),
30
+ limit: z.number().optional().describe("Deprecated limit argument.")
31
+ };
32
+ var listTasksDeprecatedSchema = {
33
+ channel: z.string().describe("Deprecated channel argument."),
34
+ status: z.enum(["all", "todo", "in_progress", "in_review", "done"]).optional().describe("Deprecated status argument.")
35
+ };
36
+ var claimTasksDeprecatedSchema = {
37
+ channel: z.string().describe("Deprecated channel argument."),
38
+ task_numbers: z.array(z.number()).optional().describe("Deprecated task numbers."),
39
+ message_ids: z.array(z.string()).optional().describe("Deprecated message ids.")
40
+ };
41
+ var unclaimTaskDeprecatedSchema = {
42
+ channel: z.string().describe("Deprecated channel argument."),
43
+ task_number: z.number().describe("Deprecated task number.")
44
+ };
45
+ var updateTaskStatusDeprecatedSchema = {
46
+ channel: z.string().describe("Deprecated channel argument."),
47
+ task_number: z.number().describe("Deprecated task number."),
48
+ status: z.enum(["todo", "in_progress", "in_review", "done"]).describe("Deprecated status argument.")
49
+ };
50
+ var DEPRECATED_MCP_TOOL_DEFINITIONS = [
51
+ {
52
+ toolName: "send_message",
53
+ description: `${DEPRECATED_MCP_SHIM_HEADER}
54
+ Use \`slock message send\` and pass the message body on stdin.`,
55
+ schema: sendMessageDeprecatedSchema,
56
+ cliExamples: [
57
+ "slock message send --target '#channel-or-dm' <<'EOF2'",
58
+ "message body",
59
+ "EOF2"
60
+ ]
61
+ },
62
+ {
63
+ toolName: "check_messages",
64
+ description: `${DEPRECATED_MCP_SHIM_HEADER}
65
+ Use \`slock message check\`.`,
66
+ schema: checkMessagesDeprecatedSchema,
67
+ cliExamples: [
68
+ "slock message check"
69
+ ]
70
+ },
71
+ {
72
+ toolName: "read_history",
73
+ description: `${DEPRECATED_MCP_SHIM_HEADER}
74
+ Use \`slock message read --channel ...\`.`,
75
+ schema: readHistoryDeprecatedSchema,
76
+ cliExamples: [
77
+ "slock message read --channel '#channel'",
78
+ "slock message read --channel 'dm:@peer'",
79
+ "slock message read --channel '#channel:threadId'"
80
+ ]
81
+ },
82
+ {
83
+ toolName: "search_messages",
84
+ description: `${DEPRECATED_MCP_SHIM_HEADER}
85
+ Use \`slock message search --query ...\`.`,
86
+ schema: searchMessagesDeprecatedSchema,
87
+ cliExamples: [
88
+ "slock message search --query 'keyword'"
89
+ ]
90
+ },
91
+ {
92
+ toolName: "list_tasks",
93
+ description: `${DEPRECATED_MCP_SHIM_HEADER}
94
+ Use \`slock task list --channel ...\`.`,
95
+ schema: listTasksDeprecatedSchema,
96
+ cliExamples: [
97
+ "slock task list --channel '#channel'"
98
+ ]
99
+ },
100
+ {
101
+ toolName: "claim_tasks",
102
+ description: `${DEPRECATED_MCP_SHIM_HEADER}
103
+ Use \`slock task claim ...\`.`,
104
+ schema: claimTasksDeprecatedSchema,
105
+ cliExamples: [
106
+ "slock task claim --channel '#channel' --number 123",
107
+ "slock task claim --channel '#channel' --message-id <messageId>"
108
+ ]
109
+ },
110
+ {
111
+ toolName: "unclaim_task",
112
+ description: `${DEPRECATED_MCP_SHIM_HEADER}
113
+ Use \`slock task unclaim ...\`.`,
114
+ schema: unclaimTaskDeprecatedSchema,
115
+ cliExamples: [
116
+ "slock task unclaim --channel '#channel' --number 123"
117
+ ]
118
+ },
119
+ {
120
+ toolName: "update_task_status",
121
+ description: `${DEPRECATED_MCP_SHIM_HEADER}
122
+ Use \`slock task update ...\`.`,
123
+ schema: updateTaskStatusDeprecatedSchema,
124
+ cliExamples: [
125
+ "slock task update --channel '#channel' --number 123 --status in_review"
126
+ ]
127
+ }
128
+ ];
129
+ function buildDeprecatedMcpToolErrorText(tool) {
130
+ return [
131
+ DEPRECATED_MCP_SHIM_HEADER,
132
+ "",
133
+ ...tool.cliExamples
134
+ ].join("\n");
135
+ }
11
136
 
12
137
  // src/historyFormatting.ts
13
138
  function toLocalHistoryTime(iso) {
@@ -210,16 +335,26 @@ var args = process.argv.slice(2);
210
335
  var agentId = "";
211
336
  var serverUrl = "http://localhost:3001";
212
337
  var authToken = "";
338
+ var runtime = "unknown";
339
+ var launchId = "";
340
+ var deprecatedShimMode = false;
213
341
  for (let i = 0; i < args.length; i++) {
214
342
  if (args[i] === "--agent-id" && args[i + 1]) agentId = args[++i];
215
343
  if (args[i] === "--server-url" && args[i + 1]) serverUrl = args[++i];
216
344
  if (args[i] === "--auth-token" && args[i + 1]) authToken = args[++i];
345
+ if (args[i] === "--runtime" && args[i + 1]) runtime = args[++i];
346
+ if (args[i] === "--launch-id" && args[i + 1]) launchId = args[++i];
347
+ if (args[i] === "--deprecated-shim") deprecatedShimMode = true;
217
348
  }
218
349
  if (!agentId) {
219
350
  console.error("Missing --agent-id");
220
351
  process.exit(1);
221
352
  }
222
353
  var commonHeaders = buildChatBridgeCommonHeaders(authToken);
354
+ var runtimeActionHeaders = {
355
+ ...commonHeaders,
356
+ ...launchId ? { "X-Agent-Launch-Id": launchId } : {}
357
+ };
223
358
  function bridgeFetch(url, init = {}) {
224
359
  const dispatcher = buildFetchDispatcher(url, process.env);
225
360
  const requestInit = dispatcher ? { ...init, dispatcher } : init;
@@ -367,61 +502,102 @@ function formatSenderHandle(message) {
367
502
  const senderDescription = message.sender_description ?? message.senderDescription ?? null;
368
503
  return senderDescription ? `@${senderName} \u2014 ${senderDescription}` : `@${senderName}`;
369
504
  }
505
+ async function listServerChannels() {
506
+ const { response: res, data } = await executeJsonRequest(
507
+ `${serverUrl}/internal/agent/${agentId}/server`,
508
+ { method: "GET", headers: commonHeaders },
509
+ {
510
+ toolName: "list_server.channels",
511
+ fetchImpl: bridgeFetch
512
+ }
513
+ );
514
+ if (!res.ok) {
515
+ throw new Error("Failed to load server channels");
516
+ }
517
+ return data.channels ?? [];
518
+ }
519
+ function parseRegularChannelTarget(target) {
520
+ if (!target.startsWith("#")) return null;
521
+ if (target.includes(":")) return null;
522
+ const name = target.slice(1).trim();
523
+ return name.length > 0 ? name : null;
524
+ }
525
+ async function resolveRegularChannelTarget(target) {
526
+ const channelName = parseRegularChannelTarget(target);
527
+ if (!channelName) {
528
+ throw new Error("Target must be a regular channel in the form '#channel-name'");
529
+ }
530
+ const channels = await listServerChannels();
531
+ const channel = channels.find((candidate) => candidate.name === channelName);
532
+ if (!channel) {
533
+ throw new Error(`Channel not found: ${target}`);
534
+ }
535
+ return channel;
536
+ }
370
537
  var server = new McpServer({
371
538
  name: "chat",
372
539
  version: "1.0.0"
373
540
  });
541
+ function logDeprecatedShimInvocation(tool) {
542
+ logger.warn(
543
+ `[ChatBridgeDeprecatedShim] tool=${tool.toolName} runtime=${runtime} agent_id=${agentId} outcome=deprecated`
544
+ );
545
+ }
546
+ function registerDeprecatedTool(tool) {
547
+ server.tool(
548
+ tool.toolName,
549
+ tool.description,
550
+ tool.schema,
551
+ async () => {
552
+ logDeprecatedShimInvocation(tool);
553
+ return {
554
+ isError: true,
555
+ content: [{ type: "text", text: buildDeprecatedMcpToolErrorText(tool) }]
556
+ };
557
+ }
558
+ );
559
+ }
560
+ if (deprecatedShimMode) {
561
+ for (const tool of DEPRECATED_MCP_TOOL_DEFINITIONS) {
562
+ registerDeprecatedTool(tool);
563
+ }
564
+ }
565
+ var RUNTIME_PROFILE_MIGRATION_DONE_TOOL_NAME = "runtime_profile_migration_done";
374
566
  server.tool(
375
- "send_message",
376
- "Send a message to a channel, DM, or thread. Use the target value from received messages to reply. Format: '#channel' for channels, 'dm:@peer' for DMs, '#channel:shortid' for threads in channels, 'dm:@peer:shortid' for threads in DMs. To start a NEW DM, use 'dm:@person-name'.",
567
+ RUNTIME_PROFILE_MIGRATION_DONE_TOOL_NAME,
568
+ "Complete the current Runtime Profile migration. This one-shot runtime control action is only valid while the agent is migrating and must use the migration_key from the private migration hint.",
377
569
  {
378
- target: z.string().describe(
379
- "Where to send. Reuse the identifier from received messages. Format: '#channel' for channels, 'dm:@name' for DMs, '#channel:id' for channel threads, 'dm:@name:id' for DM threads. Examples: '#general', 'dm:@richard', '#general:abcd1234', 'dm:@richard:abcd1234'."
380
- ),
381
- content: z.string().describe("The message content"),
382
- attachment_ids: z.array(z.string()).optional().describe("Optional attachment IDs from upload_file to include with the message")
570
+ migration_key: z2.string().describe("The migration key from the Runtime Profile migration hint")
383
571
  },
384
- async ({ target, content, attachment_ids }) => {
572
+ async ({ migration_key }) => {
573
+ const key = migration_key.trim();
574
+ if (!key) {
575
+ return {
576
+ isError: true,
577
+ content: [{ type: "text", text: "Error: migration_key is required" }]
578
+ };
579
+ }
385
580
  try {
386
- const { response: res, data } = await executeRetrySafeSendRequest(
387
- `${serverUrl}/internal/agent/${agentId}/send`,
388
- (idempotencyKey) => ({
581
+ const { response: res, data } = await executeJsonRequest(
582
+ `${serverUrl}/internal/agent/${agentId}/runtime-profile/migration-done`,
583
+ {
389
584
  method: "POST",
390
- headers: commonHeaders,
391
- body: JSON.stringify({ target, content, attachmentIds: attachment_ids, idempotencyKey })
392
- }),
585
+ headers: runtimeActionHeaders,
586
+ body: JSON.stringify({ migrationKey: key })
587
+ },
393
588
  {
394
- target,
589
+ toolName: RUNTIME_PROFILE_MIGRATION_DONE_TOOL_NAME,
395
590
  fetchImpl: bridgeFetch
396
591
  }
397
592
  );
398
593
  if (!res.ok) {
399
594
  return {
400
- content: [
401
- { type: "text", text: `Error: ${data.error}` }
402
- ]
595
+ isError: true,
596
+ content: [{ type: "text", text: `Error: ${data.error || "Runtime Profile migration is not active"}` }]
403
597
  };
404
598
  }
405
- const shortId = data.messageId ? data.messageId.slice(0, 8) : null;
406
- const replyHint = shortId ? ` (to reply in this message's thread, use target "${target.includes(":") ? target : target + ":" + shortId}")` : "";
407
- let unreadSection = "";
408
- if (data.recentUnread && data.recentUnread.length > 0) {
409
- await acknowledgeReceivedMessages(data.recentUnread);
410
- const unreadToShow = rememberDeliveredMessages(data.recentUnread);
411
- if (unreadToShow.length > 0) {
412
- unreadSection = `
413
-
414
- --- New messages you may have missed ---
415
- ${formatMessages(unreadToShow)}`;
416
- }
417
- }
418
599
  return {
419
- content: [
420
- {
421
- type: "text",
422
- text: `Message sent to ${target}. Message ID: ${data.messageId}${replyHint}${unreadSection}`
423
- }
424
- ]
600
+ content: [{ type: "text", text: "Runtime Profile migration completed. Normal inbox delivery can resume." }]
425
601
  };
426
602
  } catch (err) {
427
603
  return {
@@ -431,844 +607,951 @@ ${formatMessages(unreadToShow)}`;
431
607
  }
432
608
  }
433
609
  );
434
- server.tool(
435
- "upload_file",
436
- "Upload a file to attach to a message. Returns an attachment ID that you can pass to send_message's attachment_ids parameter. Images keep preview behavior; other files are sent as downloadable attachments. Max size: 10MB.",
437
- {
438
- file_path: z.string().describe("Absolute path to the file on your local filesystem"),
439
- channel: z.string().describe("The channel target where this file will be used (e.g. '#general', 'dm:@richard')")
440
- },
441
- async ({ file_path, channel }) => {
442
- try {
443
- const fs = await import("fs");
444
- const path = await import("path");
445
- if (!fs.existsSync(file_path)) {
610
+ if (!deprecatedShimMode) {
611
+ let formatMessages = function(messages) {
612
+ return messages.map((m) => {
613
+ const target = formatTarget(m);
614
+ const msgId = m.message_id ? m.message_id.slice(0, 8) : "-";
615
+ const time = m.timestamp ? toLocalTime(m.timestamp) : "-";
616
+ const senderType = ` type=${m.sender_type}`;
617
+ const renderedContent = m.content;
618
+ const attachSuffix = formatAttachmentSuffix(m.attachments);
619
+ const taskSuffix = m.task_status ? ` [task #${m.task_number} status=${m.task_status}${m.task_assignee_id ? ` assignee=${m.task_assignee_type}:${m.task_assignee_id}` : ""}]` : "";
620
+ return `[target=${target} msg=${msgId} time=${time}${senderType}] ${formatSenderHandle(m)}: ${renderedContent}${attachSuffix}${taskSuffix}`;
621
+ }).join("\n");
622
+ }, formatReminder = function(r) {
623
+ const fireLocal = toLocalTime(r.fireAt);
624
+ const ref = r.msgRef ? ` ref=${r.msgRef}` : "";
625
+ const repeat = r.recurrence ? ` repeat=${r.recurrence.description}` : "";
626
+ return `#${r.reminderId.slice(0, 8)} [${r.status}] fires=${fireLocal} "${r.title}"${ref}${repeat}`;
627
+ };
628
+ formatMessages2 = formatMessages, formatReminder2 = formatReminder;
629
+ server.tool(
630
+ "send_message",
631
+ "Send a message to a channel, DM, or thread. Use the target value from received messages to reply. Format: '#channel' for channels, 'dm:@peer' for DMs, '#channel:shortid' for threads in channels, 'dm:@peer:shortid' for threads in DMs. To start a NEW DM, use 'dm:@person-name'.",
632
+ {
633
+ target: z2.string().describe(
634
+ "Where to send. Reuse the identifier from received messages. Format: '#channel' for channels, 'dm:@name' for DMs, '#channel:id' for channel threads, 'dm:@name:id' for DM threads. Examples: '#general', 'dm:@richard', '#general:abcd1234', 'dm:@richard:abcd1234'."
635
+ ),
636
+ content: z2.string().describe("The message content"),
637
+ attachment_ids: z2.array(z2.string()).optional().describe("Optional attachment IDs from upload_file to include with the message")
638
+ },
639
+ async ({ target, content, attachment_ids }) => {
640
+ try {
641
+ const { response: res, data } = await executeRetrySafeSendRequest(
642
+ `${serverUrl}/internal/agent/${agentId}/send`,
643
+ (idempotencyKey) => ({
644
+ method: "POST",
645
+ headers: commonHeaders,
646
+ body: JSON.stringify({ target, content, attachmentIds: attachment_ids, idempotencyKey })
647
+ }),
648
+ {
649
+ target,
650
+ fetchImpl: bridgeFetch
651
+ }
652
+ );
653
+ if (!res.ok) {
654
+ return {
655
+ content: [
656
+ { type: "text", text: `Error: ${data.error}` }
657
+ ]
658
+ };
659
+ }
660
+ const shortId = data.messageId ? data.messageId.slice(0, 8) : null;
661
+ const replyHint = shortId ? ` (to reply in this message's thread, use target "${target.includes(":") ? target : target + ":" + shortId}")` : "";
662
+ let unreadSection = "";
663
+ if (data.recentUnread && data.recentUnread.length > 0) {
664
+ await acknowledgeReceivedMessages(data.recentUnread);
665
+ const unreadToShow = rememberDeliveredMessages(data.recentUnread);
666
+ if (unreadToShow.length > 0) {
667
+ unreadSection = `
668
+
669
+ --- New messages you may have missed ---
670
+ ${formatMessages(unreadToShow)}`;
671
+ }
672
+ }
446
673
  return {
447
- isError: true,
448
- content: [{ type: "text", text: `Error: File not found: ${file_path}` }]
674
+ content: [
675
+ {
676
+ type: "text",
677
+ text: `Message sent to ${target}. Message ID: ${data.messageId}${replyHint}${unreadSection}`
678
+ }
679
+ ]
449
680
  };
450
- }
451
- const stat = fs.statSync(file_path);
452
- if (stat.size > 10 * 1024 * 1024) {
681
+ } catch (err) {
453
682
  return {
454
683
  isError: true,
455
- content: [{ type: "text", text: `Error: File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Max 10MB per file.` }]
684
+ content: [{ type: "text", text: `Error: ${err.message}` }]
456
685
  };
457
686
  }
458
- const { response: listRes, data: listData } = await executeJsonRequest(
459
- `${serverUrl}/internal/agent/${agentId}/resolve-channel`,
460
- {
461
- method: "POST",
462
- headers: commonHeaders,
463
- body: JSON.stringify({ target: channel })
464
- },
465
- {
466
- toolName: "upload_file.resolve_channel",
467
- target: channel,
468
- fetchImpl: bridgeFetch
687
+ }
688
+ );
689
+ server.tool(
690
+ "upload_file",
691
+ "Upload a file to attach to a message. Returns an attachment ID that you can pass to send_message's attachment_ids parameter. Images keep preview behavior; other files are sent as downloadable attachments. Max size: 10MB.",
692
+ {
693
+ file_path: z2.string().describe("Absolute path to the file on your local filesystem"),
694
+ channel: z2.string().describe("The channel target where this file will be used (e.g. '#general', 'dm:@richard')")
695
+ },
696
+ async ({ file_path, channel }) => {
697
+ try {
698
+ const fs = await import("fs");
699
+ const path = await import("path");
700
+ if (!fs.existsSync(file_path)) {
701
+ return {
702
+ isError: true,
703
+ content: [{ type: "text", text: `Error: File not found: ${file_path}` }]
704
+ };
705
+ }
706
+ const stat = fs.statSync(file_path);
707
+ if (stat.size > 10 * 1024 * 1024) {
708
+ return {
709
+ isError: true,
710
+ content: [{ type: "text", text: `Error: File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Max 10MB per file.` }]
711
+ };
712
+ }
713
+ const { response: listRes, data: listData } = await executeJsonRequest(
714
+ `${serverUrl}/internal/agent/${agentId}/resolve-channel`,
715
+ {
716
+ method: "POST",
717
+ headers: commonHeaders,
718
+ body: JSON.stringify({ target: channel })
719
+ },
720
+ {
721
+ toolName: "upload_file.resolve_channel",
722
+ target: channel,
723
+ fetchImpl: bridgeFetch
724
+ }
725
+ );
726
+ if (!listRes.ok || !listData.channelId) {
727
+ return {
728
+ isError: true,
729
+ content: [{ type: "text", text: `Error: ${listData.error || `Could not resolve channel: ${channel}`}` }]
730
+ };
731
+ }
732
+ const channelId = listData.channelId;
733
+ const fileBuffer = fs.readFileSync(file_path);
734
+ const filename = path.basename(file_path);
735
+ const mimeType = guessMimeTypeFromFilename(filename);
736
+ const blob = new Blob([fileBuffer], { type: mimeType });
737
+ const formData = new FormData();
738
+ formData.append("file", blob, filename);
739
+ formData.append("channelId", channelId);
740
+ const uploadHeaders = buildChatBridgeCommonHeaders(authToken, { includeContentType: false });
741
+ const { response: res, data } = await executeJsonRequest(
742
+ `${serverUrl}/internal/agent/${agentId}/upload`,
743
+ {
744
+ method: "POST",
745
+ headers: uploadHeaders,
746
+ body: formData
747
+ },
748
+ {
749
+ toolName: "upload_file",
750
+ target: channel,
751
+ fetchImpl: bridgeFetch
752
+ }
753
+ );
754
+ if (!res.ok) {
755
+ return {
756
+ isError: true,
757
+ content: [{ type: "text", text: `Error: ${data.error}` }]
758
+ };
469
759
  }
470
- );
471
- if (!listRes.ok || !listData.channelId) {
472
760
  return {
473
- isError: true,
474
- content: [{ type: "text", text: `Error: ${listData.error || `Could not resolve channel: ${channel}`}` }]
761
+ content: [
762
+ {
763
+ type: "text",
764
+ text: `File uploaded: ${data.filename} (${(data.sizeBytes / 1024).toFixed(1)}KB)
765
+ Attachment ID: ${data.id}
766
+
767
+ Use this ID in send_message's attachment_ids parameter to include it in a message.`
768
+ }
769
+ ]
475
770
  };
476
- }
477
- const channelId = listData.channelId;
478
- const fileBuffer = fs.readFileSync(file_path);
479
- const filename = path.basename(file_path);
480
- const mimeType = guessMimeTypeFromFilename(filename);
481
- const blob = new Blob([fileBuffer], { type: mimeType });
482
- const formData = new FormData();
483
- formData.append("file", blob, filename);
484
- formData.append("channelId", channelId);
485
- const uploadHeaders = buildChatBridgeCommonHeaders(authToken, { includeContentType: false });
486
- const { response: res, data } = await executeJsonRequest(
487
- `${serverUrl}/internal/agent/${agentId}/upload`,
488
- {
489
- method: "POST",
490
- headers: uploadHeaders,
491
- body: formData
492
- },
493
- {
494
- toolName: "upload_file",
495
- target: channel,
496
- fetchImpl: bridgeFetch
497
- }
498
- );
499
- if (!res.ok) {
771
+ } catch (err) {
500
772
  return {
501
773
  isError: true,
502
- content: [{ type: "text", text: `Error: ${data.error}` }]
774
+ content: [{ type: "text", text: `Error: ${err.message}` }]
503
775
  };
504
776
  }
505
- return {
506
- content: [
777
+ }
778
+ );
779
+ server.tool(
780
+ "view_file",
781
+ "Download an attached file by its attachment ID and save it locally so you can inspect it. Returns the local file path.",
782
+ {
783
+ attachment_id: z2.string().describe("The attachment UUID (from the 'id:...' shown in the message)")
784
+ },
785
+ async ({ attachment_id }) => {
786
+ try {
787
+ const fs = await import("fs");
788
+ const path = await import("path");
789
+ const os = await import("os");
790
+ const cacheDir = path.join(os.homedir(), ".slock", "attachments");
791
+ fs.mkdirSync(cacheDir, { recursive: true });
792
+ const existing = fs.readdirSync(cacheDir).find((f) => f.startsWith(attachment_id));
793
+ if (existing) {
794
+ const cachedPath = path.join(cacheDir, existing);
795
+ return {
796
+ content: [{ type: "text", text: `File already cached at: ${cachedPath}` }]
797
+ };
798
+ }
799
+ const downloadHeaders = buildChatBridgeCommonHeaders(authToken, { includeContentType: false });
800
+ const { response: res } = await executeResponseRequest(
801
+ `${serverUrl}/api/attachments/${attachment_id}`,
507
802
  {
508
- type: "text",
509
- text: `File uploaded: ${data.filename} (${(data.sizeBytes / 1024).toFixed(1)}KB)
510
- Attachment ID: ${data.id}
511
-
512
- Use this ID in send_message's attachment_ids parameter to include it in a message.`
803
+ headers: downloadHeaders,
804
+ redirect: "follow"
805
+ },
806
+ {
807
+ toolName: "view_file",
808
+ target: attachment_id,
809
+ fetchImpl: bridgeFetch
513
810
  }
514
- ]
515
- };
516
- } catch (err) {
517
- return {
518
- isError: true,
519
- content: [{ type: "text", text: `Error: ${err.message}` }]
520
- };
521
- }
522
- }
523
- );
524
- server.tool(
525
- "view_file",
526
- "Download an attached file by its attachment ID and save it locally so you can inspect it. Returns the local file path.",
527
- {
528
- attachment_id: z.string().describe("The attachment UUID (from the 'id:...' shown in the message)")
529
- },
530
- async ({ attachment_id }) => {
531
- try {
532
- const fs = await import("fs");
533
- const path = await import("path");
534
- const os = await import("os");
535
- const cacheDir = path.join(os.homedir(), ".slock", "attachments");
536
- fs.mkdirSync(cacheDir, { recursive: true });
537
- const existing = fs.readdirSync(cacheDir).find((f) => f.startsWith(attachment_id));
538
- if (existing) {
539
- const cachedPath = path.join(cacheDir, existing);
811
+ );
812
+ if (!res.ok) {
813
+ return {
814
+ isError: true,
815
+ content: [{ type: "text", text: `Error: Failed to download attachment (${res.status})` }]
816
+ };
817
+ }
818
+ const contentType = res.headers.get("content-type") || "application/octet-stream";
819
+ const filename = parseFilenameFromContentDisposition(res.headers.get("content-disposition"));
820
+ const ext = filename ? path.extname(filename) || extensionForContentType(contentType) : extensionForContentType(contentType);
821
+ const filePath = path.join(cacheDir, `${attachment_id}${ext}`);
822
+ const buffer = Buffer.from(await res.arrayBuffer());
823
+ fs.writeFileSync(filePath, buffer);
540
824
  return {
541
- content: [{ type: "text", text: `File already cached at: ${cachedPath}` }]
825
+ content: [{ type: "text", text: `Downloaded to: ${filePath}` }]
542
826
  };
543
- }
544
- const downloadHeaders = buildChatBridgeCommonHeaders(authToken, { includeContentType: false });
545
- const { response: res } = await executeResponseRequest(
546
- `${serverUrl}/api/attachments/${attachment_id}`,
547
- {
548
- headers: downloadHeaders,
549
- redirect: "follow"
550
- },
551
- {
552
- toolName: "view_file",
553
- target: attachment_id,
554
- fetchImpl: bridgeFetch
555
- }
556
- );
557
- if (!res.ok) {
827
+ } catch (err) {
558
828
  return {
559
829
  isError: true,
560
- content: [{ type: "text", text: `Error: Failed to download attachment (${res.status})` }]
830
+ content: [{ type: "text", text: `Error: ${err.message}` }]
561
831
  };
562
832
  }
563
- const contentType = res.headers.get("content-type") || "application/octet-stream";
564
- const filename = parseFilenameFromContentDisposition(res.headers.get("content-disposition"));
565
- const ext = filename ? path.extname(filename) || extensionForContentType(contentType) : extensionForContentType(contentType);
566
- const filePath = path.join(cacheDir, `${attachment_id}${ext}`);
567
- const buffer = Buffer.from(await res.arrayBuffer());
568
- fs.writeFileSync(filePath, buffer);
569
- return {
570
- content: [{ type: "text", text: `Downloaded to: ${filePath}` }]
571
- };
572
- } catch (err) {
573
- return {
574
- isError: true,
575
- content: [{ type: "text", text: `Error: ${err.message}` }]
576
- };
577
833
  }
578
- }
579
- );
580
- server.tool(
581
- "check_messages",
582
- "Check for new messages without waiting. Returns immediately with any pending messages, or 'No new messages' if none. Use this freely during work \u2014 at natural breakpoints, after notifications, or whenever you want to see if anything new came in.",
583
- {},
584
- async () => {
585
- try {
586
- const { response: res, data } = await executeJsonRequest(
587
- `${serverUrl}/internal/agent/${agentId}/receive`,
588
- { method: "GET", headers: commonHeaders },
589
- {
590
- toolName: "check_messages",
591
- timeoutMs: 1e4,
592
- fetchImpl: bridgeFetch
834
+ );
835
+ server.tool(
836
+ "check_messages",
837
+ "Check for new messages without waiting. Returns immediately with any pending messages, or 'No new messages' if none. Use this freely during work \u2014 at natural breakpoints, after notifications, or whenever you want to see if anything new came in.",
838
+ {},
839
+ async () => {
840
+ try {
841
+ const { response: res, data } = await executeJsonRequest(
842
+ `${serverUrl}/internal/agent/${agentId}/receive`,
843
+ { method: "GET", headers: commonHeaders },
844
+ {
845
+ toolName: "check_messages",
846
+ timeoutMs: 1e4,
847
+ fetchImpl: bridgeFetch
848
+ }
849
+ );
850
+ if (!res.ok) {
851
+ return { isError: true, content: [{ type: "text", text: `Error: ${data.error || res.statusText}` }] };
593
852
  }
594
- );
595
- if (!res.ok) {
596
- return { isError: true, content: [{ type: "text", text: `Error: ${data.error || res.statusText}` }] };
597
- }
598
- const messages = data.messages ?? [];
599
- if (messages.length > 0) {
600
- await acknowledgeReceivedMessages(messages);
601
- const messagesToShow = rememberDeliveredMessages(messages);
602
- if (messagesToShow.length > 0) {
603
- return { content: [{ type: "text", text: formatMessages(messagesToShow) }] };
853
+ const messages = data.messages ?? [];
854
+ if (messages.length > 0) {
855
+ await acknowledgeReceivedMessages(messages);
856
+ const messagesToShow = rememberDeliveredMessages(messages);
857
+ if (messagesToShow.length > 0) {
858
+ return { content: [{ type: "text", text: formatMessages(messagesToShow) }] };
859
+ }
604
860
  }
861
+ return {
862
+ content: [{ type: "text", text: "No new messages." }]
863
+ };
864
+ } catch (err) {
865
+ return {
866
+ isError: true,
867
+ content: [{ type: "text", text: `Error: ${err.message}` }]
868
+ };
605
869
  }
606
- return {
607
- content: [{ type: "text", text: "No new messages." }]
608
- };
609
- } catch (err) {
610
- return {
611
- isError: true,
612
- content: [{ type: "text", text: `Error: ${err.message}` }]
613
- };
614
870
  }
615
- }
616
- );
617
- function formatMessages(messages) {
618
- return messages.map((m) => {
619
- const target = formatTarget(m);
620
- const msgId = m.message_id ? m.message_id.slice(0, 8) : "-";
621
- const time = m.timestamp ? toLocalTime(m.timestamp) : "-";
622
- const senderType = ` type=${m.sender_type}`;
623
- const renderedContent = m.content;
624
- const attachSuffix = formatAttachmentSuffix(m.attachments);
625
- const taskSuffix = m.task_status ? ` [task #${m.task_number} status=${m.task_status}${m.task_assignee_id ? ` assignee=${m.task_assignee_type}:${m.task_assignee_id}` : ""}]` : "";
626
- return `[target=${target} msg=${msgId} time=${time}${senderType}] ${formatSenderHandle(m)}: ${renderedContent}${attachSuffix}${taskSuffix}`;
627
- }).join("\n");
628
- }
629
- server.tool(
630
- "list_server",
631
- "List all channels in this server, including which ones you have joined, plus all agents and humans. Use this to discover who and where you can message.",
632
- {},
633
- async () => {
634
- try {
635
- const { response: res, data } = await executeJsonRequest(
636
- `${serverUrl}/internal/agent/${agentId}/server`,
637
- { method: "GET", headers: commonHeaders },
638
- {
639
- toolName: "list_server",
640
- fetchImpl: bridgeFetch
641
- }
642
- );
643
- let text = "## Server\n\n";
644
- const channels = data.channels ?? [];
645
- const agents = data.agents ?? [];
646
- const humans = data.humans ?? [];
647
- text += "### Channels\n";
648
- text += 'Visible public channels may appear even when `joined=false`. Use `read_history(channel="#name")` to inspect them. When a channel is not joined, you cannot send messages there or receive ordinary channel delivery until a human adds you to the channel.\n';
649
- if (channels.length > 0) {
650
- for (const t of channels) {
651
- const status = t.joined ? "joined" : "not joined";
652
- text += t.description ? ` - #${t.name} [${status}] \u2014 ${t.description}
871
+ );
872
+ server.tool(
873
+ "list_server",
874
+ "List all channels in this server, including which ones you have joined, plus all agents and humans. Use this to discover who and where you can message.",
875
+ {},
876
+ async () => {
877
+ try {
878
+ const { response: res, data } = await executeJsonRequest(
879
+ `${serverUrl}/internal/agent/${agentId}/server`,
880
+ { method: "GET", headers: commonHeaders },
881
+ {
882
+ toolName: "list_server",
883
+ fetchImpl: bridgeFetch
884
+ }
885
+ );
886
+ let text = "## Server\n\n";
887
+ const channels = data.channels ?? [];
888
+ const agents = data.agents ?? [];
889
+ const humans = data.humans ?? [];
890
+ text += "### Channels\n";
891
+ text += 'Visible public channels may appear even when `joined=false`. Use `read_history(channel="#name")` to inspect them. When a channel is not joined, you cannot send messages there or receive ordinary channel delivery until a human adds you to the channel. To leave a regular channel you have joined, use `leave_channel(target="#name")`.\n';
892
+ if (channels.length > 0) {
893
+ for (const t of channels) {
894
+ const status = t.joined ? "joined" : "not joined";
895
+ text += t.description ? ` - #${t.name} [${status}] \u2014 ${t.description}
653
896
  ` : ` - #${t.name} [${status}]
654
897
  `;
898
+ }
899
+ } else {
900
+ text += " (none)\n";
655
901
  }
656
- } else {
657
- text += " (none)\n";
658
- }
659
- text += "\n### Agents\n";
660
- text += "Other AI agents in this server.\n";
661
- if (agents.length > 0) {
662
- for (const a of agents) {
663
- text += a.description ? ` - @${a.name} (${a.status}) \u2014 ${a.description}
902
+ text += "\n### Agents\n";
903
+ text += "Other AI agents in this server.\n";
904
+ if (agents.length > 0) {
905
+ for (const a of agents) {
906
+ text += a.description ? ` - @${a.name} (${a.status}) \u2014 ${a.description}
664
907
  ` : ` - @${a.name} (${a.status})
665
908
  `;
909
+ }
910
+ } else {
911
+ text += " (none)\n";
666
912
  }
667
- } else {
668
- text += " (none)\n";
669
- }
670
- text += "\n### Humans\n";
671
- text += 'To start a new DM: send_message(target="dm:@name"). To reply in an existing DM: reuse the target from received messages.\n';
672
- if (humans.length > 0) {
673
- for (const u of humans) {
674
- text += u.description ? ` - @${u.name} \u2014 ${u.description}
913
+ text += "\n### Humans\n";
914
+ text += 'To start a new DM: send_message(target="dm:@name"). To reply in an existing DM: reuse the target from received messages.\n';
915
+ if (humans.length > 0) {
916
+ for (const u of humans) {
917
+ text += u.description ? ` - @${u.name} \u2014 ${u.description}
675
918
  ` : ` - @${u.name}
676
919
  `;
920
+ }
921
+ } else {
922
+ text += " (none)\n";
677
923
  }
678
- } else {
679
- text += " (none)\n";
680
- }
681
- return {
682
- content: [{ type: "text", text }]
683
- };
684
- } catch (err) {
685
- return {
686
- isError: true,
687
- content: [{ type: "text", text: `Error: ${err.message}` }]
688
- };
689
- }
690
- }
691
- );
692
- server.tool(
693
- "search_messages",
694
- "Search messages visible to the agent. Use this to find relevant conversations, then inspect a hit with read_history(channel=..., around=messageId).",
695
- {
696
- query: z.string().describe("Search query"),
697
- channel: z.string().optional().describe("Optional target to scope the search, e.g. '#general', 'dm:@richard', '#general:abcd1234'"),
698
- sender_id: z.string().optional().describe("Optional exact sender id filter."),
699
- after: z.string().optional().describe("Optional inclusive ISO datetime lower bound for message created_at."),
700
- before: z.string().optional().describe("Optional inclusive ISO datetime upper bound for message created_at."),
701
- limit: z.number().default(10).describe("Max number of search results to return (default 10, max 20)")
702
- },
703
- async ({ query, channel, sender_id, after, before, limit }) => {
704
- try {
705
- const trimmed = query.trim();
706
- if (!trimmed) {
707
924
  return {
708
- content: [{ type: "text", text: "Search query cannot be empty." }]
925
+ content: [{ type: "text", text }]
926
+ };
927
+ } catch (err) {
928
+ return {
929
+ isError: true,
930
+ content: [{ type: "text", text: `Error: ${err.message}` }]
709
931
  };
710
932
  }
711
- const params = new URLSearchParams();
712
- params.set("q", trimmed);
713
- params.set("limit", String(Math.min(limit, 20)));
714
- if (channel) params.set("channel", channel);
715
- if (sender_id) params.set("senderId", sender_id);
716
- if (after) params.set("after", after);
717
- if (before) params.set("before", before);
718
- const { response: res, data } = await executeJsonRequest(
719
- `${serverUrl}/internal/agent/${agentId}/search?${params}`,
720
- { method: "GET", headers: commonHeaders },
721
- {
722
- toolName: "search_messages",
723
- target: channel ?? null,
724
- fetchImpl: bridgeFetch
933
+ }
934
+ );
935
+ server.tool(
936
+ "leave_channel",
937
+ "Leave a regular channel you have joined. This only affects your own agent membership; it does not require admin privileges. After leaving, you can still inspect visible public channel history, but you will stop receiving ordinary channel delivery and cannot send until a human adds you again.",
938
+ {
939
+ target: z2.string().describe("Regular channel to leave, in the form '#channel-name'. DMs and thread targets are not supported.")
940
+ },
941
+ async ({ target }) => {
942
+ try {
943
+ const channel = await resolveRegularChannelTarget(target);
944
+ if (!channel.joined) {
945
+ return {
946
+ content: [{ type: "text", text: `Already not joined in ${target}.` }]
947
+ };
948
+ }
949
+ const { response: res, data } = await executeJsonRequest(
950
+ `${serverUrl}/internal/agent/${agentId}/channels/${channel.id}/leave`,
951
+ {
952
+ method: "POST",
953
+ headers: commonHeaders
954
+ },
955
+ {
956
+ toolName: "leave_channel",
957
+ target,
958
+ fetchImpl: bridgeFetch
959
+ }
960
+ );
961
+ if (!res.ok) {
962
+ return {
963
+ isError: true,
964
+ content: [{ type: "text", text: `Error: ${data.error || `Failed to leave ${target}`}` }]
965
+ };
725
966
  }
726
- );
727
- if (!res.ok) {
728
967
  return {
729
- content: [{ type: "text", text: `Error: ${data.error}` }]
968
+ content: [{ type: "text", text: `Left ${target}. You can still inspect visible public channel history there, but you can no longer send or receive ordinary channel delivery until a human adds you again.` }]
730
969
  };
731
- }
732
- if (!data.results || data.results.length === 0) {
970
+ } catch (err) {
733
971
  return {
734
- content: [{ type: "text", text: "No search results." }]
972
+ isError: true,
973
+ content: [{ type: "text", text: `Error: ${err.message}` }]
735
974
  };
736
975
  }
737
- const formatted = data.results.map((result, index) => {
738
- const target = formatSearchTarget(result);
739
- const threadInfo = result.channelType === "thread" ? `
976
+ }
977
+ );
978
+ server.tool(
979
+ "search_messages",
980
+ "Search messages visible to the agent. Use this to find relevant conversations, then inspect a hit with read_history(channel=..., around=messageId).",
981
+ {
982
+ query: z2.string().describe("Search query"),
983
+ channel: z2.string().optional().describe("Optional target to scope the search, e.g. '#general', 'dm:@richard', '#general:abcd1234'"),
984
+ sender_id: z2.string().optional().describe("Optional exact sender id filter."),
985
+ after: z2.string().optional().describe("Optional inclusive ISO datetime lower bound for message created_at."),
986
+ before: z2.string().optional().describe("Optional inclusive ISO datetime upper bound for message created_at."),
987
+ limit: z2.number().default(10).describe("Max number of search results to return (default 10, max 20)")
988
+ },
989
+ async ({ query, channel, sender_id, after, before, limit }) => {
990
+ try {
991
+ const trimmed = query.trim();
992
+ if (!trimmed) {
993
+ return {
994
+ content: [{ type: "text", text: "Search query cannot be empty." }]
995
+ };
996
+ }
997
+ const params = new URLSearchParams();
998
+ params.set("q", trimmed);
999
+ params.set("limit", String(Math.min(limit, 20)));
1000
+ if (channel) params.set("channel", channel);
1001
+ if (sender_id) params.set("senderId", sender_id);
1002
+ if (after) params.set("after", after);
1003
+ if (before) params.set("before", before);
1004
+ const { response: res, data } = await executeJsonRequest(
1005
+ `${serverUrl}/internal/agent/${agentId}/search?${params}`,
1006
+ { method: "GET", headers: commonHeaders },
1007
+ {
1008
+ toolName: "search_messages",
1009
+ target: channel ?? null,
1010
+ fetchImpl: bridgeFetch
1011
+ }
1012
+ );
1013
+ if (!res.ok) {
1014
+ return {
1015
+ content: [{ type: "text", text: `Error: ${data.error}` }]
1016
+ };
1017
+ }
1018
+ if (!data.results || data.results.length === 0) {
1019
+ return {
1020
+ content: [{ type: "text", text: "No search results." }]
1021
+ };
1022
+ }
1023
+ const formatted = data.results.map((result, index) => {
1024
+ const target = formatSearchTarget(result);
1025
+ const threadInfo = result.channelType === "thread" ? `
740
1026
  thread: ${result.parentChannelName} -> ${target}` : "";
741
- return [
742
- `[${index + 1}] msg=${result.id} seq=${result.seq} time=${toLocalTime(result.createdAt)}`,
743
- `target: ${target}${threadInfo}`,
744
- `sender: @${result.senderName} (${result.senderType})`,
745
- `content: ${result.content}`,
746
- `match: ${result.snippet}`,
747
- `next: read_history(channel="${target}", around="${result.id}", limit=20)`
748
- ].join("\n");
749
- }).join("\n\n");
750
- return {
751
- content: [{
752
- type: "text",
753
- text: `## Search Results for "${trimmed}" (${data.results.length} results)
1027
+ return [
1028
+ `[${index + 1}] msg=${result.id} seq=${result.seq} time=${toLocalTime(result.createdAt)}`,
1029
+ `target: ${target}${threadInfo}`,
1030
+ `sender: @${result.senderName} (${result.senderType})`,
1031
+ `content: ${result.content}`,
1032
+ `match: ${result.snippet}`,
1033
+ `next: read_history(channel="${target}", around="${result.id}", limit=20)`
1034
+ ].join("\n");
1035
+ }).join("\n\n");
1036
+ return {
1037
+ content: [{
1038
+ type: "text",
1039
+ text: `## Search Results for "${trimmed}" (${data.results.length} results)
754
1040
 
755
1041
  ${formatted}`
756
- }]
757
- };
758
- } catch (err) {
759
- return {
760
- isError: true,
761
- content: [{ type: "text", text: `Error: ${err.message}` }]
762
- };
763
- }
764
- }
765
- );
766
- server.tool(
767
- "read_history",
768
- "Read message history for a channel, DM, or thread. Use the same target format: '#channel', 'dm:@name', '#channel:id' for threads, 'dm:@name:id' for DM threads. Supports pagination via 'before' / 'after', and context jumps via 'around' (messageId or seq).",
769
- {
770
- channel: z.string().describe("The target to read history from \u2014 e.g. '#general', 'dm:@richard', '#general:abcd1234', 'dm:@richard:abcd1234'"),
771
- limit: z.number().default(50).describe("Max number of messages to return (default 50, max 100)"),
772
- around: z.union([z.string(), z.number()]).optional().describe("Center the result window around a messageId or seq in this channel/thread."),
773
- before: z.number().optional().describe("Return messages before this seq number (for backward pagination). Omit for latest messages."),
774
- after: z.number().optional().describe("Return messages after this seq number (for catching up on unread). Returns oldest-first.")
775
- },
776
- async ({ channel, limit, around, before, after }) => {
777
- try {
778
- const params = new URLSearchParams();
779
- params.set("channel", channel);
780
- params.set("limit", String(Math.min(limit, 100)));
781
- if (around !== void 0) params.set("around", String(around));
782
- if (before) params.set("before", String(before));
783
- if (after) params.set("after", String(after));
784
- const { response: res, data } = await executeJsonRequest(
785
- `${serverUrl}/internal/agent/${agentId}/history?${params}`,
786
- { method: "GET", headers: commonHeaders },
787
- {
788
- toolName: "read_history",
789
- target: channel,
790
- fetchImpl: bridgeFetch
791
- }
792
- );
793
- if (!res.ok) {
794
- return {
795
- content: [
796
- { type: "text", text: `Error: ${data.error}` }
797
- ]
1042
+ }]
798
1043
  };
799
- }
800
- if (!data.messages || data.messages.length === 0) {
1044
+ } catch (err) {
801
1045
  return {
802
- content: [
803
- { type: "text", text: "No messages in this channel." }
804
- ]
1046
+ isError: true,
1047
+ content: [{ type: "text", text: `Error: ${err.message}` }]
805
1048
  };
806
1049
  }
807
- const formatted = data.messages.map((m) => formatHistoryMessageLine({
808
- ...m,
809
- senderName: m.senderName ?? m.sender_name ?? "unknown",
810
- senderDescription: m.senderDescription ?? m.sender_description ?? null
811
- })).join("\n");
812
- let footer = "";
813
- if (data.historyLimited) {
814
- footer = `
1050
+ }
1051
+ );
1052
+ server.tool(
1053
+ "read_history",
1054
+ "Read message history for a channel, DM, or thread. Use the same target format: '#channel', 'dm:@name', '#channel:id' for threads, 'dm:@name:id' for DM threads. Supports pagination via 'before' / 'after', and context jumps via 'around' (messageId or seq).",
1055
+ {
1056
+ channel: z2.string().describe("The target to read history from \u2014 e.g. '#general', 'dm:@richard', '#general:abcd1234', 'dm:@richard:abcd1234'"),
1057
+ limit: z2.number().default(50).describe("Max number of messages to return (default 50, max 100)"),
1058
+ around: z2.union([z2.string(), z2.number()]).optional().describe("Center the result window around a messageId or seq in this channel/thread."),
1059
+ before: z2.number().optional().describe("Return messages before this seq number (for backward pagination). Omit for latest messages."),
1060
+ after: z2.number().optional().describe("Return messages after this seq number (for catching up on unread). Returns oldest-first.")
1061
+ },
1062
+ async ({ channel, limit, around, before, after }) => {
1063
+ try {
1064
+ const params = new URLSearchParams();
1065
+ params.set("channel", channel);
1066
+ params.set("limit", String(Math.min(limit, 100)));
1067
+ if (around !== void 0) params.set("around", String(around));
1068
+ if (before) params.set("before", String(before));
1069
+ if (after) params.set("after", String(after));
1070
+ const { response: res, data } = await executeJsonRequest(
1071
+ `${serverUrl}/internal/agent/${agentId}/history?${params}`,
1072
+ { method: "GET", headers: commonHeaders },
1073
+ {
1074
+ toolName: "read_history",
1075
+ target: channel,
1076
+ fetchImpl: bridgeFetch
1077
+ }
1078
+ );
1079
+ if (!res.ok) {
1080
+ return {
1081
+ content: [
1082
+ { type: "text", text: `Error: ${data.error}` }
1083
+ ]
1084
+ };
1085
+ }
1086
+ if (!data.messages || data.messages.length === 0) {
1087
+ return {
1088
+ content: [
1089
+ { type: "text", text: "No messages in this channel." }
1090
+ ]
1091
+ };
1092
+ }
1093
+ const formatted = data.messages.map((m) => formatHistoryMessageLine({
1094
+ ...m,
1095
+ senderName: m.senderName ?? m.sender_name ?? "unknown",
1096
+ senderDescription: m.senderDescription ?? m.sender_description ?? null
1097
+ })).join("\n");
1098
+ let footer = "";
1099
+ if (data.historyLimited) {
1100
+ footer = `
815
1101
 
816
1102
  --- ${data.historyLimitMessage || "Message history is limited on this plan."} ---`;
817
- } else if (around && data.messages.length > 0 && (data.has_older || data.has_newer)) {
818
- const minSeq = data.messages[0].seq;
819
- const maxSeq = data.messages[data.messages.length - 1].seq;
820
- footer = `
821
-
822
- --- Context window shown. Use before=${minSeq} to load older messages or after=${maxSeq} to load newer messages. ---`;
823
- } else if (data.has_more && data.messages.length > 0) {
824
- if (after) {
1103
+ } else if (around && data.messages.length > 0 && (data.has_older || data.has_newer)) {
1104
+ const minSeq = data.messages[0].seq;
825
1105
  const maxSeq = data.messages[data.messages.length - 1].seq;
826
1106
  footer = `
827
1107
 
1108
+ --- Context window shown. Use before=${minSeq} to load older messages or after=${maxSeq} to load newer messages. ---`;
1109
+ } else if (data.has_more && data.messages.length > 0) {
1110
+ if (after) {
1111
+ const maxSeq = data.messages[data.messages.length - 1].seq;
1112
+ footer = `
1113
+
828
1114
  --- ${data.messages.length} messages shown. Use after=${maxSeq} to load more recent messages. ---`;
829
- } else {
830
- const minSeq = data.messages[0].seq;
831
- footer = `
1115
+ } else {
1116
+ const minSeq = data.messages[0].seq;
1117
+ footer = `
832
1118
 
833
1119
  --- ${data.messages.length} messages shown. Use before=${minSeq} to load older messages. ---`;
1120
+ }
834
1121
  }
835
- }
836
- let header = `## Message History for ${channel}${around ? ` around ${around}` : ""} (${data.messages.length} messages)`;
837
- if ((data.last_read_seq ?? 0) > 0 && !after && !before && !around) {
838
- header += `
1122
+ let header = `## Message History for ${channel}${around ? ` around ${around}` : ""} (${data.messages.length} messages)`;
1123
+ if ((data.last_read_seq ?? 0) > 0 && !after && !before && !around) {
1124
+ header += `
839
1125
  Your last read position: seq ${data.last_read_seq}. Use read_history(channel="${channel}", after=${data.last_read_seq}) to see only unread messages.`;
840
- }
841
- return {
842
- content: [
843
- {
844
- type: "text",
845
- text: `${header}
846
-
847
- ${formatted}${footer}`
848
- }
849
- ]
850
- };
851
- } catch (err) {
852
- return {
853
- isError: true,
854
- content: [{ type: "text", text: `Error: ${err.message}` }]
855
- };
856
- }
857
- }
858
- );
859
- server.tool(
860
- "list_tasks",
861
- "List all tasks in a channel. Returns each task's number, title, status, assignee, and message ID. Use this to see what work exists before claiming. Tasks marked as legacy are from an older system and cannot be claimed or modified.",
862
- {
863
- channel: z.string().describe("The channel whose task board to view \u2014 e.g. '#engineering', '#proj-slock'"),
864
- status: z.enum(["all", "todo", "in_progress", "in_review", "done"]).default("all").describe("Filter by status (default: all)")
865
- },
866
- async ({ channel, status }) => {
867
- try {
868
- const params = new URLSearchParams();
869
- params.set("channel", channel);
870
- if (status !== "all") params.set("status", status);
871
- const { response: res, data } = await executeJsonRequest(
872
- `${serverUrl}/internal/agent/${agentId}/tasks?${params}`,
873
- { method: "GET", headers: commonHeaders },
874
- {
875
- toolName: "list_tasks",
876
- target: channel,
877
- fetchImpl: bridgeFetch
878
1126
  }
879
- );
880
- if (!res.ok) {
881
1127
  return {
882
- isError: true,
883
- content: [{ type: "text", text: `Error: ${data.error}` }]
1128
+ content: [
1129
+ {
1130
+ type: "text",
1131
+ text: `${header}
1132
+
1133
+ ${formatted}${footer}`
1134
+ }
1135
+ ]
884
1136
  };
885
- }
886
- if (!data.tasks || data.tasks.length === 0) {
1137
+ } catch (err) {
887
1138
  return {
888
- content: [{ type: "text", text: `No${status !== "all" ? ` ${status}` : ""} tasks in ${channel}.` }]
1139
+ isError: true,
1140
+ content: [{ type: "text", text: `Error: ${err.message}` }]
889
1141
  };
890
1142
  }
891
- const formatted = data.tasks.map((t) => {
892
- const assignee = t.claimedByName ? ` \u2192 @${t.claimedByName}` : "";
893
- const creator = t.createdByName ? ` (by @${t.createdByName})` : "";
894
- const msgId = t.messageId ? ` msg=${t.messageId.slice(0, 8)}` : "";
895
- const legacy = t.isLegacy ? " [LEGACY \u2014 read-only]" : "";
896
- return `#${t.taskNumber} [${t.status}] ${t.title}${assignee}${creator}${msgId}${legacy}`;
897
- }).join("\n");
898
- return {
899
- content: [
1143
+ }
1144
+ );
1145
+ server.tool(
1146
+ "list_tasks",
1147
+ "List all tasks in a channel. Returns each task's number, title, status, assignee, and message ID. Use this to see what work exists before claiming. Tasks marked as legacy are from an older system and cannot be claimed or modified.",
1148
+ {
1149
+ channel: z2.string().describe("The channel whose task board to view \u2014 e.g. '#engineering', '#proj-slock'"),
1150
+ status: z2.enum(["all", "todo", "in_progress", "in_review", "done"]).default("all").describe("Filter by status (default: all)")
1151
+ },
1152
+ async ({ channel, status }) => {
1153
+ try {
1154
+ const params = new URLSearchParams();
1155
+ params.set("channel", channel);
1156
+ if (status !== "all") params.set("status", status);
1157
+ const { response: res, data } = await executeJsonRequest(
1158
+ `${serverUrl}/internal/agent/${agentId}/tasks?${params}`,
1159
+ { method: "GET", headers: commonHeaders },
900
1160
  {
901
- type: "text",
902
- text: `## Task Board for ${channel} (${data.tasks.length} tasks)
903
-
904
- ${formatted}`
1161
+ toolName: "list_tasks",
1162
+ target: channel,
1163
+ fetchImpl: bridgeFetch
905
1164
  }
906
- ]
907
- };
908
- } catch (err) {
909
- return {
910
- isError: true,
911
- content: [{ type: "text", text: `Error: ${err.message}` }]
912
- };
913
- }
914
- }
915
- );
916
- server.tool(
917
- "create_tasks",
918
- "Create one or more new task-messages in a top-level channel or DM. This is a convenience helper for creating a brand-new message and publishing it as a task-message in the chat flow. Thread messages cannot become tasks. It does not claim the task for you; if you want to own it, still call claim_tasks afterward. It is not a separate task board outside the chat flow. Typical uses are breaking down a larger task into parallel subtasks or batch-creating new work for others to claim. Do not use this to convert an existing message \u2014 use claim_tasks with message_ids instead. If the work already exists as a task, either claim that task or leave it alone; do not create a second task/message for the same work.",
919
- {
920
- channel: z.string().describe("The channel to create tasks in \u2014 e.g. '#engineering'"),
921
- tasks: z.array(
922
- z.object({
923
- title: z.string().describe("Task title")
924
- })
925
- ).describe("Array of tasks to create")
926
- },
927
- async ({ channel, tasks }) => {
928
- try {
929
- const { response: res, data } = await executeJsonRequest(
930
- `${serverUrl}/internal/agent/${agentId}/tasks`,
931
- {
932
- method: "POST",
933
- headers: commonHeaders,
934
- body: JSON.stringify({ channel, tasks })
935
- },
936
- {
937
- toolName: "create_tasks",
938
- target: channel,
939
- fetchImpl: bridgeFetch
1165
+ );
1166
+ if (!res.ok) {
1167
+ return {
1168
+ isError: true,
1169
+ content: [{ type: "text", text: `Error: ${data.error}` }]
1170
+ };
940
1171
  }
941
- );
942
- if (!res.ok) {
1172
+ if (!data.tasks || data.tasks.length === 0) {
1173
+ return {
1174
+ content: [{ type: "text", text: `No${status !== "all" ? ` ${status}` : ""} tasks in ${channel}.` }]
1175
+ };
1176
+ }
1177
+ const formatted = data.tasks.map((t) => {
1178
+ const assignee = t.claimedByName ? ` \u2192 @${t.claimedByName}` : "";
1179
+ const creator = t.createdByName ? ` (by @${t.createdByName})` : "";
1180
+ const msgId = t.messageId ? ` msg=${t.messageId.slice(0, 8)}` : "";
1181
+ const legacy = t.isLegacy ? " [LEGACY \u2014 read-only]" : "";
1182
+ return `#${t.taskNumber} [${t.status}] ${t.title}${assignee}${creator}${msgId}${legacy}`;
1183
+ }).join("\n");
1184
+ return {
1185
+ content: [
1186
+ {
1187
+ type: "text",
1188
+ text: `## Task Board for ${channel} (${data.tasks.length} tasks)
1189
+
1190
+ ${formatted}`
1191
+ }
1192
+ ]
1193
+ };
1194
+ } catch (err) {
943
1195
  return {
944
1196
  isError: true,
945
- content: [{ type: "text", text: `Error: ${data.error}` }]
1197
+ content: [{ type: "text", text: `Error: ${err.message}` }]
946
1198
  };
947
1199
  }
948
- const created = data.tasks.map((t) => `#${t.taskNumber} msg=${t.messageId.slice(0, 8)} "${t.title}"`).join("\n");
949
- const threadHints = data.tasks.map((t) => `#${t.taskNumber} \u2192 send_message to "${channel}:${t.messageId.slice(0, 8)}"`).join("\n");
950
- return {
951
- content: [
1200
+ }
1201
+ );
1202
+ server.tool(
1203
+ "create_tasks",
1204
+ "Create one or more new task-messages in a top-level channel or DM. This is a convenience helper for creating a brand-new message and publishing it as a task-message in the chat flow. Thread messages cannot become tasks. It does not claim the task for you; if you want to own it, still call claim_tasks afterward. It is not a separate task board outside the chat flow. Typical uses are breaking down a larger task into parallel subtasks or batch-creating new work for others to claim. Do not use this to convert an existing message \u2014 use claim_tasks with message_ids instead. If the work already exists as a task, either claim that task or leave it alone; do not create a second task/message for the same work.",
1205
+ {
1206
+ channel: z2.string().describe("The channel to create tasks in \u2014 e.g. '#engineering'"),
1207
+ tasks: z2.array(
1208
+ z2.object({
1209
+ title: z2.string().describe("Task title")
1210
+ })
1211
+ ).describe("Array of tasks to create")
1212
+ },
1213
+ async ({ channel, tasks }) => {
1214
+ try {
1215
+ const { response: res, data } = await executeJsonRequest(
1216
+ `${serverUrl}/internal/agent/${agentId}/tasks`,
952
1217
  {
953
- type: "text",
954
- text: `Created ${data.tasks.length} task(s) in ${channel}:
1218
+ method: "POST",
1219
+ headers: commonHeaders,
1220
+ body: JSON.stringify({ channel, tasks })
1221
+ },
1222
+ {
1223
+ toolName: "create_tasks",
1224
+ target: channel,
1225
+ fetchImpl: bridgeFetch
1226
+ }
1227
+ );
1228
+ if (!res.ok) {
1229
+ return {
1230
+ isError: true,
1231
+ content: [{ type: "text", text: `Error: ${data.error}` }]
1232
+ };
1233
+ }
1234
+ const created = data.tasks.map((t) => `#${t.taskNumber} msg=${t.messageId.slice(0, 8)} "${t.title}"`).join("\n");
1235
+ const threadHints = data.tasks.map((t) => `#${t.taskNumber} \u2192 send_message to "${channel}:${t.messageId.slice(0, 8)}"`).join("\n");
1236
+ return {
1237
+ content: [
1238
+ {
1239
+ type: "text",
1240
+ text: `Created ${data.tasks.length} task(s) in ${channel}:
955
1241
  ${created}
956
1242
 
957
1243
  To follow up in each task's thread:
958
1244
  ${threadHints}`
959
- }
960
- ]
961
- };
962
- } catch (err) {
963
- return {
964
- isError: true,
965
- content: [{ type: "text", text: `Error: ${err.message}` }]
966
- };
1245
+ }
1246
+ ]
1247
+ };
1248
+ } catch (err) {
1249
+ return {
1250
+ isError: true,
1251
+ content: [{ type: "text", text: `Error: ${err.message}` }]
1252
+ };
1253
+ }
967
1254
  }
968
- }
969
- );
970
- server.tool(
971
- "claim_tasks",
972
- `Claim tasks so you are assigned to work on them. Two modes:
1255
+ );
1256
+ server.tool(
1257
+ "claim_tasks",
1258
+ `Claim tasks so you are assigned to work on them. Two modes:
973
1259
  1. By task number: claim existing tasks shown in list_tasks. Use task_numbers=[1, 3].
974
1260
  2. By message ID: convert a regular top-level message into a task and claim it. Use message_ids=["a1b2c3d4"]. The message ID is the 8-character msg= value from received messages or read_history.
975
1261
 
976
1262
  Thread messages and system messages (e.g. task-claim / task-status announcements) cannot be claimed or converted into tasks \u2014 if a system message describes an action you should take, just do it; otherwise ignore it. If a task is in "todo" status, claiming auto-advances it to "in_progress". If another agent already claimed it, the claim fails \u2014 do not work on that task, move on. Always claim before starting any work to prevent duplicate effort.`,
977
- {
978
- channel: z.string().describe("The channel \u2014 e.g. '#engineering'"),
979
- task_numbers: z.array(z.number()).optional().describe("Task numbers to claim (from list_tasks output, e.g. [1, 3])"),
980
- message_ids: z.array(z.string()).optional().describe("Message IDs or short ID prefixes (the 8-char msg= value, e.g. ['a1b2c3d4']). Converts a regular top-level message to a task and claims it. Thread messages are not allowed.")
981
- },
982
- async ({ channel, task_numbers, message_ids }) => {
983
- try {
984
- if ((!task_numbers || task_numbers.length === 0) && (!message_ids || message_ids.length === 0)) {
1263
+ {
1264
+ channel: z2.string().describe("The channel \u2014 e.g. '#engineering'"),
1265
+ task_numbers: z2.array(z2.number()).optional().describe("Task numbers to claim (from list_tasks output, e.g. [1, 3])"),
1266
+ message_ids: z2.array(z2.string()).optional().describe("Message IDs or short ID prefixes (the 8-char msg= value, e.g. ['a1b2c3d4']). Converts a regular top-level message to a task and claims it. Thread messages are not allowed.")
1267
+ },
1268
+ async ({ channel, task_numbers, message_ids }) => {
1269
+ try {
1270
+ if ((!task_numbers || task_numbers.length === 0) && (!message_ids || message_ids.length === 0)) {
1271
+ return {
1272
+ content: [{ type: "text", text: "Error: provide at least one of task_numbers or message_ids" }]
1273
+ };
1274
+ }
1275
+ const body = { channel };
1276
+ if (task_numbers && task_numbers.length > 0) body.task_numbers = task_numbers;
1277
+ if (message_ids && message_ids.length > 0) body.message_ids = message_ids;
1278
+ const { response: res, data } = await executeJsonRequest(
1279
+ `${serverUrl}/internal/agent/${agentId}/tasks/claim`,
1280
+ {
1281
+ method: "POST",
1282
+ headers: commonHeaders,
1283
+ body: JSON.stringify(body)
1284
+ },
1285
+ {
1286
+ toolName: "claim_tasks",
1287
+ target: channel,
1288
+ fetchImpl: bridgeFetch
1289
+ }
1290
+ );
1291
+ if (!res.ok) {
1292
+ return {
1293
+ isError: true,
1294
+ content: [{ type: "text", text: `Error: ${data.error}` }]
1295
+ };
1296
+ }
1297
+ const lines = data.results.map((r) => {
1298
+ const label = r.taskNumber ? `#${r.taskNumber}` : `msg:${r.messageId}`;
1299
+ if (r.success) {
1300
+ const msgShort = r.messageId ? r.messageId.slice(0, 8) : "";
1301
+ return `${label} (msg:${msgShort}): claimed`;
1302
+ }
1303
+ return `${label}: FAILED \u2014 ${r.reason || "already claimed"}`;
1304
+ });
1305
+ const succeeded = data.results.filter((r) => r.success).length;
1306
+ const failed = data.results.length - succeeded;
1307
+ let summary = `${succeeded} claimed`;
1308
+ if (failed > 0) summary += `, ${failed} failed`;
1309
+ const claimedMsgs = data.results.filter((r) => r.success && r.messageId).map((r) => `#${r.taskNumber} \u2192 send_message to "${channel}:${r.messageId.slice(0, 8)}"`).join("\n");
1310
+ const threadHint = claimedMsgs ? `
1311
+
1312
+ Follow up in each task's thread:
1313
+ ${claimedMsgs}` : "";
985
1314
  return {
986
- content: [{ type: "text", text: "Error: provide at least one of task_numbers or message_ids" }]
1315
+ content: [
1316
+ {
1317
+ type: "text",
1318
+ text: `Claim results (${summary}):
1319
+ ${lines.join("\n")}${threadHint}`
1320
+ }
1321
+ ]
987
1322
  };
988
- }
989
- const body = { channel };
990
- if (task_numbers && task_numbers.length > 0) body.task_numbers = task_numbers;
991
- if (message_ids && message_ids.length > 0) body.message_ids = message_ids;
992
- const { response: res, data } = await executeJsonRequest(
993
- `${serverUrl}/internal/agent/${agentId}/tasks/claim`,
994
- {
995
- method: "POST",
996
- headers: commonHeaders,
997
- body: JSON.stringify(body)
998
- },
999
- {
1000
- toolName: "claim_tasks",
1001
- target: channel,
1002
- fetchImpl: bridgeFetch
1003
- }
1004
- );
1005
- if (!res.ok) {
1323
+ } catch (err) {
1006
1324
  return {
1007
1325
  isError: true,
1008
- content: [{ type: "text", text: `Error: ${data.error}` }]
1326
+ content: [{ type: "text", text: `Error: ${err.message}` }]
1009
1327
  };
1010
1328
  }
1011
- const lines = data.results.map((r) => {
1012
- const label = r.taskNumber ? `#${r.taskNumber}` : `msg:${r.messageId}`;
1013
- if (r.success) {
1014
- const msgShort = r.messageId ? r.messageId.slice(0, 8) : "";
1015
- return `${label} (msg:${msgShort}): claimed`;
1016
- }
1017
- return `${label}: FAILED \u2014 ${r.reason || "already claimed"}`;
1018
- });
1019
- const succeeded = data.results.filter((r) => r.success).length;
1020
- const failed = data.results.length - succeeded;
1021
- let summary = `${succeeded} claimed`;
1022
- if (failed > 0) summary += `, ${failed} failed`;
1023
- const claimedMsgs = data.results.filter((r) => r.success && r.messageId).map((r) => `#${r.taskNumber} \u2192 send_message to "${channel}:${r.messageId.slice(0, 8)}"`).join("\n");
1024
- const threadHint = claimedMsgs ? `
1025
-
1026
- Follow up in each task's thread:
1027
- ${claimedMsgs}` : "";
1028
- return {
1029
- content: [
1329
+ }
1330
+ );
1331
+ server.tool(
1332
+ "unclaim_task",
1333
+ "Release your claim on a task so someone else can pick it up. Only use this if you can no longer work on the task \u2014 not as a way to mark it done. Use update_task_status to change status instead.",
1334
+ {
1335
+ channel: z2.string().describe("The channel \u2014 e.g. '#engineering'"),
1336
+ task_number: z2.number().describe("The task number to unclaim (e.g. 3)")
1337
+ },
1338
+ async ({ channel, task_number }) => {
1339
+ try {
1340
+ const { response: res, data } = await executeJsonRequest(
1341
+ `${serverUrl}/internal/agent/${agentId}/tasks/unclaim`,
1030
1342
  {
1031
- type: "text",
1032
- text: `Claim results (${summary}):
1033
- ${lines.join("\n")}${threadHint}`
1343
+ method: "POST",
1344
+ headers: commonHeaders,
1345
+ body: JSON.stringify({ channel, task_number })
1346
+ },
1347
+ {
1348
+ toolName: "unclaim_task",
1349
+ target: channel,
1350
+ fetchImpl: bridgeFetch
1034
1351
  }
1035
- ]
1036
- };
1037
- } catch (err) {
1038
- return {
1039
- isError: true,
1040
- content: [{ type: "text", text: `Error: ${err.message}` }]
1041
- };
1042
- }
1043
- }
1044
- );
1045
- server.tool(
1046
- "unclaim_task",
1047
- "Release your claim on a task so someone else can pick it up. Only use this if you can no longer work on the task \u2014 not as a way to mark it done. Use update_task_status to change status instead.",
1048
- {
1049
- channel: z.string().describe("The channel \u2014 e.g. '#engineering'"),
1050
- task_number: z.number().describe("The task number to unclaim (e.g. 3)")
1051
- },
1052
- async ({ channel, task_number }) => {
1053
- try {
1054
- const { response: res, data } = await executeJsonRequest(
1055
- `${serverUrl}/internal/agent/${agentId}/tasks/unclaim`,
1056
- {
1057
- method: "POST",
1058
- headers: commonHeaders,
1059
- body: JSON.stringify({ channel, task_number })
1060
- },
1061
- {
1062
- toolName: "unclaim_task",
1063
- target: channel,
1064
- fetchImpl: bridgeFetch
1352
+ );
1353
+ if (!res.ok) {
1354
+ return {
1355
+ isError: true,
1356
+ content: [{ type: "text", text: `Error: ${data.error}` }]
1357
+ };
1065
1358
  }
1066
- );
1067
- if (!res.ok) {
1359
+ return {
1360
+ content: [
1361
+ { type: "text", text: `#${task_number} unclaimed \u2014 now open.` }
1362
+ ]
1363
+ };
1364
+ } catch (err) {
1068
1365
  return {
1069
1366
  isError: true,
1070
- content: [{ type: "text", text: `Error: ${data.error}` }]
1367
+ content: [{ type: "text", text: `Error: ${err.message}` }]
1071
1368
  };
1072
1369
  }
1073
- return {
1074
- content: [
1075
- { type: "text", text: `#${task_number} unclaimed \u2014 now open.` }
1076
- ]
1077
- };
1078
- } catch (err) {
1079
- return {
1080
- isError: true,
1081
- content: [{ type: "text", text: `Error: ${err.message}` }]
1082
- };
1083
1370
  }
1084
- }
1085
- );
1086
- server.tool(
1087
- "update_task_status",
1088
- "Update a task's progress status. You must be the task's assignee to update it. Use in_review when your work is ready for human validation. Only set done for trivial tasks or after explicit approval. Valid transitions: todo\u2192in_progress, in_progress\u2192in_review or done, in_review\u2192done or back to in_progress.",
1089
- {
1090
- channel: z.string().describe("The channel \u2014 e.g. '#engineering'"),
1091
- task_number: z.number().describe("The task number to update (e.g. 3)"),
1092
- status: z.enum(["todo", "in_progress", "in_review", "done"]).describe("The new status")
1093
- },
1094
- async ({ channel, task_number, status }) => {
1095
- try {
1096
- const { response: res, data } = await executeJsonRequest(
1097
- `${serverUrl}/internal/agent/${agentId}/tasks/update-status`,
1098
- {
1099
- method: "POST",
1100
- headers: commonHeaders,
1101
- body: JSON.stringify({ channel, task_number, status })
1102
- },
1103
- {
1104
- toolName: "update_task_status",
1105
- target: channel,
1106
- fetchImpl: bridgeFetch
1371
+ );
1372
+ server.tool(
1373
+ "update_task_status",
1374
+ "Update a task's progress status. You must be the task's assignee to update it. Use in_review when your work is ready for human validation. Only set done for trivial tasks or after explicit approval. Valid transitions: todo\u2192in_progress, in_progress\u2192in_review or done, in_review\u2192done or back to in_progress.",
1375
+ {
1376
+ channel: z2.string().describe("The channel \u2014 e.g. '#engineering'"),
1377
+ task_number: z2.number().describe("The task number to update (e.g. 3)"),
1378
+ status: z2.enum(["todo", "in_progress", "in_review", "done"]).describe("The new status")
1379
+ },
1380
+ async ({ channel, task_number, status }) => {
1381
+ try {
1382
+ const { response: res, data } = await executeJsonRequest(
1383
+ `${serverUrl}/internal/agent/${agentId}/tasks/update-status`,
1384
+ {
1385
+ method: "POST",
1386
+ headers: commonHeaders,
1387
+ body: JSON.stringify({ channel, task_number, status })
1388
+ },
1389
+ {
1390
+ toolName: "update_task_status",
1391
+ target: channel,
1392
+ fetchImpl: bridgeFetch
1393
+ }
1394
+ );
1395
+ if (!res.ok) {
1396
+ return {
1397
+ isError: true,
1398
+ content: [{ type: "text", text: `Error: ${data.error}` }]
1399
+ };
1107
1400
  }
1108
- );
1109
- if (!res.ok) {
1401
+ return {
1402
+ content: [
1403
+ { type: "text", text: `#${task_number} moved to ${status}.` }
1404
+ ]
1405
+ };
1406
+ } catch (err) {
1110
1407
  return {
1111
1408
  isError: true,
1112
- content: [{ type: "text", text: `Error: ${data.error}` }]
1409
+ content: [{ type: "text", text: `Error: ${err.message}` }]
1113
1410
  };
1114
1411
  }
1115
- return {
1116
- content: [
1117
- { type: "text", text: `#${task_number} moved to ${status}.` }
1118
- ]
1119
- };
1120
- } catch (err) {
1121
- return {
1122
- isError: true,
1123
- content: [{ type: "text", text: `Error: ${err.message}` }]
1124
- };
1125
1412
  }
1126
- }
1127
- );
1128
- function formatReminder(r) {
1129
- const fireLocal = toLocalTime(r.fireAt);
1130
- const ref = r.msgRef ? ` ref=${r.msgRef}` : "";
1131
- const repeat = r.recurrence ? ` repeat=${r.recurrence.description}` : "";
1132
- return `#${r.reminderId.slice(0, 8)} [${r.status}] fires=${fireLocal} "${r.title}"${ref}${repeat}`;
1133
- }
1134
- server.tool(
1135
- "schedule_reminder",
1136
- "Schedule a reminder that will fire at a future time and wake you up with a DM. Use this when you need to follow up on something after a delay, at a specific time, or on a schedule. The reminder persists across daemon restarts. For one-shot reminders, provide delay_seconds (preferred) OR fire_at. For recurring reminders, provide repeat; you may also combine repeat with delay_seconds or fire_at to pin the first fire.",
1137
- {
1138
- title: z.string().describe("Short description of what the reminder is about. This is what you'll see when it fires."),
1139
- delay_seconds: z.number().int().positive().optional().describe("Preferred for relative times. Fires this many seconds from now (server-computed, timezone-safe). Use this for any 'in N seconds/minutes/hours' request."),
1140
- fire_at: z.string().optional().describe("ISO-8601 UTC timestamp, e.g. '2026-04-21T09:00:00Z'. Use only for absolute calendar times ('tomorrow 9am UTC'). Your local clock is NOT trusted as UTC \u2014 if you mean a relative delay, use delay_seconds instead."),
1141
- repeat: z.string().optional().describe("Recurrence rule. Supported forms: 'every:15m' | 'every:2h' | 'every:1d' (fixed interval) | 'daily@09:00' (in your local tz, snapshotted at creation) | 'weekly:mon,fri@09:00' (specific weekdays). The reminder auto-reschedules after each fire until you cancel it."),
1142
- channel: z.string().optional().describe("Optional explicit channel to post a receipt system message in (format: '#channel', 'dm:@name', or thread ref). Use this only if you want the receipt somewhere other than the anchor message's channel."),
1143
- msg_id: z.string().describe("Required anchor message id (from a received message). Resolve it explicitly and pass it in; if you cannot resolve one, do not create the reminder.")
1144
- },
1145
- async ({ title, delay_seconds, fire_at, repeat, channel, msg_id }) => {
1146
- try {
1147
- const body = { title, msgId: msg_id };
1148
- if (delay_seconds !== void 0) body.delaySeconds = delay_seconds;
1149
- if (fire_at !== void 0) body.fireAt = fire_at;
1150
- if (repeat !== void 0) {
1151
- body.repeat = repeat;
1152
- body.tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
1153
- }
1154
- if (channel !== void 0) body.channel = channel;
1155
- const { response: res, data } = await executeJsonRequest(
1156
- `${serverUrl}/internal/agent/${agentId}/reminders`,
1157
- {
1158
- method: "POST",
1159
- headers: commonHeaders,
1160
- body: JSON.stringify(body)
1161
- },
1162
- {
1163
- toolName: "schedule_reminder",
1164
- fetchImpl: bridgeFetch
1413
+ );
1414
+ server.tool(
1415
+ "schedule_reminder",
1416
+ "Schedule a reminder that will fire at a future time and wake you up with a DM. Use this when you need to follow up on something after a delay, at a specific time, or on a schedule. The reminder persists across daemon restarts. For one-shot reminders, provide delay_seconds (preferred) OR fire_at. For recurring reminders, provide repeat; you may also combine repeat with delay_seconds or fire_at to pin the first fire.",
1417
+ {
1418
+ title: z2.string().describe("Short description of what the reminder is about. This is what you'll see when it fires."),
1419
+ delay_seconds: z2.number().int().positive().optional().describe("Preferred for relative times. Fires this many seconds from now (server-computed, timezone-safe). Use this for any 'in N seconds/minutes/hours' request."),
1420
+ fire_at: z2.string().optional().describe("ISO-8601 UTC timestamp, e.g. '2026-04-21T09:00:00Z'. Use only for absolute calendar times ('tomorrow 9am UTC'). Your local clock is NOT trusted as UTC \u2014 if you mean a relative delay, use delay_seconds instead."),
1421
+ repeat: z2.string().optional().describe("Recurrence rule. Supported forms: 'every:15m' | 'every:2h' | 'every:1d' (fixed interval) | 'daily@09:00' (in your local tz, snapshotted at creation) | 'weekly:mon,fri@09:00' (specific weekdays). The reminder auto-reschedules after each fire until you cancel it."),
1422
+ channel: z2.string().optional().describe("Optional explicit channel to post a receipt system message in (format: '#channel', 'dm:@name', or thread ref). Use this only if you want the receipt somewhere other than the anchor message's channel."),
1423
+ msg_id: z2.string().describe("Required anchor message id (from a received message). Resolve it explicitly and pass it in; if you cannot resolve one, do not create the reminder.")
1424
+ },
1425
+ async ({ title, delay_seconds, fire_at, repeat, channel, msg_id }) => {
1426
+ try {
1427
+ const body = { title, msgId: msg_id };
1428
+ if (delay_seconds !== void 0) body.delaySeconds = delay_seconds;
1429
+ if (fire_at !== void 0) body.fireAt = fire_at;
1430
+ if (repeat !== void 0) {
1431
+ body.repeat = repeat;
1432
+ body.tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
1165
1433
  }
1166
- );
1167
- if (!res.ok || !data.reminder) {
1168
- return { isError: true, content: [{ type: "text", text: `Error: ${data.error || res.statusText}` }] };
1169
- }
1170
- const lines = [`Reminder scheduled: ${formatReminder(data.reminder)}`];
1171
- if (data.warning) lines.push(`Warning: ${data.warning}`);
1172
- return { content: [{ type: "text", text: lines.join("\n") }] };
1173
- } catch (err) {
1174
- return {
1175
- isError: true,
1176
- content: [{ type: "text", text: `Error: ${err.message}` }]
1177
- };
1178
- }
1179
- }
1180
- );
1181
- server.tool(
1182
- "list_reminders",
1183
- "List your own reminders. Defaults to scheduled (pending) ones; pass status to include fired or canceled.",
1184
- {
1185
- status: z.string().optional().describe("Comma-separated statuses to include (scheduled,fired,canceled). Defaults to 'scheduled'.")
1186
- },
1187
- async ({ status }) => {
1188
- try {
1189
- const qs = new URLSearchParams();
1190
- qs.set("status", status && status.trim().length > 0 ? status.trim() : "scheduled");
1191
- const { response: res, data } = await executeJsonRequest(
1192
- `${serverUrl}/internal/agent/${agentId}/reminders?${qs.toString()}`,
1193
- { method: "GET", headers: commonHeaders },
1194
- {
1195
- toolName: "list_reminders",
1196
- fetchImpl: bridgeFetch
1434
+ if (channel !== void 0) body.channel = channel;
1435
+ const { response: res, data } = await executeJsonRequest(
1436
+ `${serverUrl}/internal/agent/${agentId}/reminders`,
1437
+ {
1438
+ method: "POST",
1439
+ headers: commonHeaders,
1440
+ body: JSON.stringify(body)
1441
+ },
1442
+ {
1443
+ toolName: "schedule_reminder",
1444
+ fetchImpl: bridgeFetch
1445
+ }
1446
+ );
1447
+ if (!res.ok || !data.reminder) {
1448
+ return { isError: true, content: [{ type: "text", text: `Error: ${data.error || res.statusText}` }] };
1197
1449
  }
1198
- );
1199
- if (!res.ok) {
1200
- return { isError: true, content: [{ type: "text", text: `Error: ${data.error || res.statusText}` }] };
1201
- }
1202
- const list = data.reminders ?? [];
1203
- if (list.length === 0) {
1204
- return { content: [{ type: "text", text: "No reminders." }] };
1450
+ const lines = [`Reminder scheduled: ${formatReminder(data.reminder)}`];
1451
+ if (data.warning) lines.push(`Warning: ${data.warning}`);
1452
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1453
+ } catch (err) {
1454
+ return {
1455
+ isError: true,
1456
+ content: [{ type: "text", text: `Error: ${err.message}` }]
1457
+ };
1205
1458
  }
1206
- return {
1207
- content: [
1208
- { type: "text", text: list.map(formatReminder).join("\n") }
1209
- ]
1210
- };
1211
- } catch (err) {
1212
- return {
1213
- isError: true,
1214
- content: [{ type: "text", text: `Error: ${err.message}` }]
1215
- };
1216
1459
  }
1217
- }
1218
- );
1219
- server.tool(
1220
- "cancel_reminder",
1221
- "Cancel one of your own scheduled reminders by id. Only reminders in 'scheduled' status can be canceled.",
1222
- {
1223
- reminder_id: z.string().describe("The reminder id (full uuid, or the short 8-char prefix shown by schedule_reminder / list_reminders).")
1224
- },
1225
- async ({ reminder_id }) => {
1226
- try {
1227
- let fullId = reminder_id;
1228
- if (reminder_id.length < 32) {
1229
- const { response: listRes, data: listData } = await executeJsonRequest(
1230
- `${serverUrl}/internal/agent/${agentId}/reminders?status=scheduled`,
1460
+ );
1461
+ server.tool(
1462
+ "list_reminders",
1463
+ "List your own reminders. Defaults to scheduled (pending) ones; pass status to include fired or canceled.",
1464
+ {
1465
+ status: z2.string().optional().describe("Comma-separated statuses to include (scheduled,fired,canceled). Defaults to 'scheduled'.")
1466
+ },
1467
+ async ({ status }) => {
1468
+ try {
1469
+ const qs = new URLSearchParams();
1470
+ qs.set("status", status && status.trim().length > 0 ? status.trim() : "scheduled");
1471
+ const { response: res, data } = await executeJsonRequest(
1472
+ `${serverUrl}/internal/agent/${agentId}/reminders?${qs.toString()}`,
1231
1473
  { method: "GET", headers: commonHeaders },
1232
1474
  {
1233
- toolName: "cancel_reminder.resolve",
1475
+ toolName: "list_reminders",
1234
1476
  fetchImpl: bridgeFetch
1235
1477
  }
1236
1478
  );
1237
- if (!listRes.ok) {
1238
- return { isError: true, content: [{ type: "text", text: `Error: ${listData.error || listRes.statusText}` }] };
1479
+ if (!res.ok) {
1480
+ return { isError: true, content: [{ type: "text", text: `Error: ${data.error || res.statusText}` }] };
1239
1481
  }
1240
- const matches = (listData.reminders ?? []).filter((r) => r.reminderId.startsWith(reminder_id));
1241
- if (matches.length === 0) {
1242
- return { isError: true, content: [{ type: "text", text: `No scheduled reminder matches id prefix '${reminder_id}'.` }] };
1482
+ const list = data.reminders ?? [];
1483
+ if (list.length === 0) {
1484
+ return { content: [{ type: "text", text: "No reminders." }] };
1243
1485
  }
1244
- if (matches.length > 1) {
1245
- return { isError: true, content: [{ type: "text", text: `Ambiguous id prefix '${reminder_id}' matches ${matches.length} reminders; pass a longer id.` }] };
1246
- }
1247
- fullId = matches[0].reminderId;
1486
+ return {
1487
+ content: [
1488
+ { type: "text", text: list.map(formatReminder).join("\n") }
1489
+ ]
1490
+ };
1491
+ } catch (err) {
1492
+ return {
1493
+ isError: true,
1494
+ content: [{ type: "text", text: `Error: ${err.message}` }]
1495
+ };
1248
1496
  }
1249
- const { response: res, data } = await executeJsonRequest(
1250
- `${serverUrl}/internal/agent/${agentId}/reminders/${fullId}`,
1251
- { method: "DELETE", headers: commonHeaders },
1252
- {
1253
- toolName: "cancel_reminder",
1254
- fetchImpl: bridgeFetch
1497
+ }
1498
+ );
1499
+ server.tool(
1500
+ "cancel_reminder",
1501
+ "Cancel one of your own scheduled reminders by id. Only reminders in 'scheduled' status can be canceled.",
1502
+ {
1503
+ reminder_id: z2.string().describe("The reminder id (full uuid, or the short 8-char prefix shown by schedule_reminder / list_reminders).")
1504
+ },
1505
+ async ({ reminder_id }) => {
1506
+ try {
1507
+ let fullId = reminder_id;
1508
+ if (reminder_id.length < 32) {
1509
+ const { response: listRes, data: listData } = await executeJsonRequest(
1510
+ `${serverUrl}/internal/agent/${agentId}/reminders?status=scheduled`,
1511
+ { method: "GET", headers: commonHeaders },
1512
+ {
1513
+ toolName: "cancel_reminder.resolve",
1514
+ fetchImpl: bridgeFetch
1515
+ }
1516
+ );
1517
+ if (!listRes.ok) {
1518
+ return { isError: true, content: [{ type: "text", text: `Error: ${listData.error || listRes.statusText}` }] };
1519
+ }
1520
+ const matches = (listData.reminders ?? []).filter((r) => r.reminderId.startsWith(reminder_id));
1521
+ if (matches.length === 0) {
1522
+ return { isError: true, content: [{ type: "text", text: `No scheduled reminder matches id prefix '${reminder_id}'.` }] };
1523
+ }
1524
+ if (matches.length > 1) {
1525
+ return { isError: true, content: [{ type: "text", text: `Ambiguous id prefix '${reminder_id}' matches ${matches.length} reminders; pass a longer id.` }] };
1526
+ }
1527
+ fullId = matches[0].reminderId;
1255
1528
  }
1256
- );
1257
- if (!res.ok || !data.reminder) {
1258
- return { isError: true, content: [{ type: "text", text: `Error: ${data.error || res.statusText}` }] };
1529
+ const { response: res, data } = await executeJsonRequest(
1530
+ `${serverUrl}/internal/agent/${agentId}/reminders/${fullId}`,
1531
+ { method: "DELETE", headers: commonHeaders },
1532
+ {
1533
+ toolName: "cancel_reminder",
1534
+ fetchImpl: bridgeFetch
1535
+ }
1536
+ );
1537
+ if (!res.ok || !data.reminder) {
1538
+ return { isError: true, content: [{ type: "text", text: `Error: ${data.error || res.statusText}` }] };
1539
+ }
1540
+ return {
1541
+ content: [
1542
+ { type: "text", text: `Reminder canceled: ${formatReminder(data.reminder)}` }
1543
+ ]
1544
+ };
1545
+ } catch (err) {
1546
+ return {
1547
+ isError: true,
1548
+ content: [{ type: "text", text: `Error: ${err.message}` }]
1549
+ };
1259
1550
  }
1260
- return {
1261
- content: [
1262
- { type: "text", text: `Reminder canceled: ${formatReminder(data.reminder)}` }
1263
- ]
1264
- };
1265
- } catch (err) {
1266
- return {
1267
- isError: true,
1268
- content: [{ type: "text", text: `Error: ${err.message}` }]
1269
- };
1270
1551
  }
1271
- }
1272
- );
1552
+ );
1553
+ }
1554
+ var formatMessages2;
1555
+ var formatReminder2;
1273
1556
  var transport = new StdioServerTransport();
1274
1557
  await server.connect(transport);