@friendlyrobot/discord-pi-agent 0.4.7 → 0.5.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.
@@ -1,3 +1,4 @@
1
+ import { type AgentSession } from "@mariozechner/pi-coding-agent";
1
2
  import type { AgentStatus, ResolvedDiscordPiBridgeConfig, ThinkingLevel } from "./types";
2
3
  export declare class AgentService {
3
4
  private readonly config;
@@ -8,6 +9,9 @@ export declare class AgentService {
8
9
  private session;
9
10
  constructor(config: ResolvedDiscordPiBridgeConfig);
10
11
  initialize(): Promise<void>;
12
+ getSession(): AgentSession | null;
13
+ getAgentDir(): string;
14
+ createSession(sessionDir: string): Promise<AgentSession>;
11
15
  prompt(text: string): Promise<string>;
12
16
  reloadResources(): Promise<string>;
13
17
  compact(): Promise<string>;
@@ -16,8 +20,10 @@ export declare class AgentService {
16
20
  shutdown(): Promise<void>;
17
21
  private createOrResumeSession;
18
22
  private ensureConfiguredModel;
23
+ private ensureModelForSession;
19
24
  private requireSession;
20
25
  private applyConfiguredThinkingLevel;
26
+ private applyConfiguredThinkingLevelForSession;
21
27
  getThinkingLevel(): {
22
28
  current: ThinkingLevel;
23
29
  available: ThinkingLevel[];
@@ -1,8 +1,15 @@
1
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
1
2
  import type { AgentService } from "./agent-service";
2
3
  import type { PromptQueue } from "./prompt-queue";
3
- type CommandResult = {
4
+ export type CommandResult = {
4
5
  handled: boolean;
5
6
  response?: string;
7
+ /** Set to true when the command wants to archive the current thread. */
8
+ archive?: boolean;
6
9
  };
7
- export declare function handleCommand(input: string, agentService: AgentService, promptQueue: PromptQueue): Promise<CommandResult>;
8
- export {};
10
+ export type CommandContext = {
11
+ agentService: AgentService;
12
+ promptQueue: PromptQueue;
13
+ session?: AgentSession;
14
+ };
15
+ export declare function handleCommand(input: string, ctx: CommandContext): Promise<CommandResult>;
package/dist/config.d.ts CHANGED
@@ -1,3 +1,10 @@
1
- import type { DiscordPiBridgeConfig, ResolvedDiscordPiBridgeConfig } from "./types";
1
+ import type { DiscordGatewayConfig, DiscordPiBridgeConfig, ResolvedDiscordGatewayConfig, ResolvedDiscordPiBridgeConfig } from "./types";
2
2
  export declare function resolveConfig(config: DiscordPiBridgeConfig): ResolvedDiscordPiBridgeConfig;
3
3
  export declare function loadDiscordPiBridgeConfigFromEnv(overrides?: Partial<DiscordPiBridgeConfig>): ResolvedDiscordPiBridgeConfig;
4
+ /**
5
+ * Load gateway config from env vars + overrides.
6
+ * Preserves gateway-specific fields (forum channels, etc.) that
7
+ * loadDiscordPiBridgeConfigFromEnv would drop.
8
+ */
9
+ export declare function loadDiscordGatewayConfigFromEnv(overrides?: Partial<DiscordGatewayConfig>): ResolvedDiscordGatewayConfig;
10
+ export declare function resolveGatewayConfig(config: DiscordGatewayConfig): ResolvedDiscordGatewayConfig;
@@ -0,0 +1,11 @@
1
+ import { Client } from "discord.js";
2
+ import type { AgentService } from "./agent-service";
3
+ import type { SessionRegistry } from "./session-registry";
4
+ import type { ResolvedDiscordPiBridgeConfig } from "./types";
5
+ export type GatewayAuthConfig = {
6
+ discordAllowedUserId: string;
7
+ discordAllowedForumChannelIds: string[];
8
+ discordAllowedUserIds: string[];
9
+ startupMessage: string | false;
10
+ };
11
+ export declare function startGatewayClient(config: ResolvedDiscordPiBridgeConfig, agentService: AgentService, sessionRegistry: SessionRegistry, authConfig: GatewayAuthConfig): Promise<Client>;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,13 @@
1
- import type { DiscordPiBridge, DiscordPiBridgeConfig } from "./types";
1
+ import type { DiscordGateway, DiscordGatewayConfig, DiscordPiBridge, DiscordPiBridgeConfig } from "./types";
2
2
  export { buildTimeContextPrompt, type TimeContextPromptOptions, } from "./prompt-context";
3
- export { loadDiscordPiBridgeConfigFromEnv, resolveConfig } from "./config";
4
- export type { AgentStatus, DiscordPiBridge, DiscordPiBridgeConfig, PromptTransform, ResolvedDiscordPiBridgeConfig, } from "./types";
3
+ export { loadDiscordPiBridgeConfigFromEnv, loadDiscordGatewayConfigFromEnv, resolveConfig, } from "./config";
4
+ export type { AgentStatus, DiscordGateway, DiscordGatewayConfig, DiscordPiBridge, DiscordPiBridgeConfig, PromptTransform, ResolvedDiscordPiBridgeConfig, } from "./types";
5
+ /**
6
+ * Start the unified Discord gateway. Supports DM and forum thread sessions
7
+ * out of the box. Set discordAllowedForumChannelIds to enable forum support.
8
+ */
9
+ export declare function startDiscordGateway(config: DiscordGatewayConfig): Promise<DiscordGateway>;
10
+ /**
11
+ * Legacy DM-only entry point. Now a thin wrapper over startDiscordGateway.
12
+ */
5
13
  export declare function startDiscordPiBridge(config: DiscordPiBridgeConfig): Promise<DiscordPiBridge>;
package/dist/index.js CHANGED
@@ -180,6 +180,32 @@ class AgentService {
180
180
  await this.createOrResumeSession();
181
181
  await this.ensureConfiguredModel();
182
182
  }
183
+ getSession() {
184
+ return this.session;
185
+ }
186
+ getAgentDir() {
187
+ return this.config.agentDir;
188
+ }
189
+ async createSession(sessionDir) {
190
+ await fs.mkdir(sessionDir, { recursive: true });
191
+ const { session } = await createAgentSession({
192
+ cwd: this.config.cwd,
193
+ agentDir: this.config.agentDir,
194
+ authStorage: this.authStorage,
195
+ modelRegistry: this.modelRegistry,
196
+ resourceLoader: this.resourceLoader,
197
+ settingsManager: this.settingsManager,
198
+ sessionManager: SessionManager.continueRecent(this.config.cwd, sessionDir),
199
+ thinkingLevel: this.config.thinkingLevel
200
+ });
201
+ console.log("[agent] scoped session created", {
202
+ sessionDir,
203
+ sessionId: session.sessionId,
204
+ sessionFile: session.sessionFile
205
+ });
206
+ await this.ensureModelForSession(session);
207
+ return session;
208
+ }
183
209
  async prompt(text) {
184
210
  const session = this.requireSession();
185
211
  const transformedPrompt = await this.config.promptTransform(text);
@@ -263,7 +289,9 @@ class AgentService {
263
289
  });
264
290
  }
265
291
  async ensureConfiguredModel() {
266
- const session = this.requireSession();
292
+ await this.ensureModelForSession(this.requireSession());
293
+ }
294
+ async ensureModelForSession(session) {
267
295
  const desiredModel = this.modelRegistry.find(this.config.modelProvider, this.config.modelId);
268
296
  const availableModels = await this.modelRegistry.getAvailable();
269
297
  console.log("[agent] available models", {
@@ -286,7 +314,7 @@ class AgentService {
286
314
  to: `${desiredModel.provider}/${desiredModel.id}`
287
315
  });
288
316
  await session.setModel(desiredModel);
289
- await this.applyConfiguredThinkingLevel();
317
+ await this.applyConfiguredThinkingLevelForSession(session);
290
318
  }
291
319
  requireSession() {
292
320
  if (!this.session) {
@@ -295,7 +323,9 @@ class AgentService {
295
323
  return this.session;
296
324
  }
297
325
  async applyConfiguredThinkingLevel() {
298
- const session = this.requireSession();
326
+ await this.applyConfiguredThinkingLevelForSession(this.requireSession());
327
+ }
328
+ async applyConfiguredThinkingLevelForSession(session) {
299
329
  if (session.supportsThinking()) {
300
330
  const available = session.getAvailableThinkingLevels();
301
331
  if (available.includes(this.config.thinkingLevel)) {
@@ -377,6 +407,17 @@ function loadDiscordPiBridgeConfigFromEnv(overrides = {}) {
377
407
  shutdownOnSignals: overrides.shutdownOnSignals
378
408
  });
379
409
  }
410
+ function loadDiscordGatewayConfigFromEnv(overrides = {}) {
411
+ const base = loadDiscordPiBridgeConfigFromEnv(overrides);
412
+ return {
413
+ ...base,
414
+ discordAllowedForumChannelIds: overrides.discordAllowedForumChannelIds ?? parseStringArrayFromEnv("DISCORD_FORUM_CHANNEL_IDS") ?? [],
415
+ discordAllowedUserIds: overrides.discordAllowedUserIds ?? parseStringArrayFromEnv("DISCORD_ALLOWED_USER_IDS") ?? [
416
+ base.discordAllowedUserId
417
+ ],
418
+ sessionIdleTimeoutMs: overrides.sessionIdleTimeoutMs ?? parseOptionalIntFromEnv("DISCORD_SESSION_IDLE_TIMEOUT_MS") ?? null
419
+ };
420
+ }
380
421
  function readRequiredValue(name, value) {
381
422
  const trimmedValue = value.trim();
382
423
  if (!trimmedValue) {
@@ -395,6 +436,17 @@ function readStartupMessageFromEnv() {
395
436
  }
396
437
  return trimmedValue;
397
438
  }
439
+ function resolveGatewayConfig(config) {
440
+ const base = resolveConfig(config);
441
+ return {
442
+ ...base,
443
+ discordAllowedForumChannelIds: config.discordAllowedForumChannelIds ?? [],
444
+ discordAllowedUserIds: config.discordAllowedUserIds ?? [
445
+ base.discordAllowedUserId
446
+ ],
447
+ sessionIdleTimeoutMs: config.sessionIdleTimeoutMs ?? null
448
+ };
449
+ }
398
450
  function parseThinkingLevel(value) {
399
451
  if (!value) {
400
452
  return;
@@ -416,8 +468,23 @@ function parseThinkingLevel(value) {
416
468
  function identityPromptTransform(input) {
417
469
  return input;
418
470
  }
471
+ function parseStringArrayFromEnv(key) {
472
+ const value = process.env[key];
473
+ if (!value) {
474
+ return;
475
+ }
476
+ return value.split(",").map((id) => id.trim()).filter(Boolean);
477
+ }
478
+ function parseOptionalIntFromEnv(key) {
479
+ const value = process.env[key];
480
+ if (!value) {
481
+ return;
482
+ }
483
+ const parsed = parseInt(value, 10);
484
+ return Number.isNaN(parsed) ? undefined : parsed;
485
+ }
419
486
 
420
- // src/discord-client.ts
487
+ // src/discord-gateway-client.ts
421
488
  import {
422
489
  ChannelType,
423
490
  Client,
@@ -427,12 +494,43 @@ import {
427
494
  } from "discord.js";
428
495
 
429
496
  // src/commands.ts
430
- async function handleCommand(input, agentService, promptQueue) {
497
+ function getSessionStatusText(session, promptQueue) {
498
+ const model = session.model ? `${session.model.provider}/${session.model.id}` : "(no model selected)";
499
+ const contextUsage = session.getContextUsage();
500
+ const contextLine = contextUsage ? contextUsage.tokens === null || contextUsage.percent === null ? `context: ?/${contextUsage.contextWindow}` : `context: ${contextUsage.tokens}/${contextUsage.contextWindow} (${Math.round(contextUsage.percent)}%)` : "context: (unavailable)";
501
+ const thinkingInfo = session.supportsThinking() ? `thinking: ${session.thinkingLevel} (available: ${session.getAvailableThinkingLevels().join(", ")})` : "thinking: not supported";
502
+ const queueStatus = promptQueue.getSnapshot();
503
+ return [
504
+ `model: ${model}`,
505
+ `session-id: ${session.sessionId}`,
506
+ `session-file: ${session.sessionFile ?? "(none)"}`,
507
+ `streaming: ${session.isStreaming}`,
508
+ thinkingInfo,
509
+ contextLine,
510
+ `queue-pending: ${queueStatus.pending}`,
511
+ `queue-busy: ${queueStatus.busy}`
512
+ ].join(`
513
+ `);
514
+ }
515
+ function getThinkingInfo(session) {
516
+ if (!session.supportsThinking()) {
517
+ return { current: "off", available: [], supported: false };
518
+ }
519
+ return {
520
+ current: session.thinkingLevel,
521
+ available: session.getAvailableThinkingLevels(),
522
+ supported: true
523
+ };
524
+ }
525
+ async function handleCommand(input, ctx) {
526
+ const { agentService, promptQueue, session } = ctx;
431
527
  const trimmed = input.trim();
432
528
  if (!trimmed.startsWith("!")) {
433
529
  return { handled: false };
434
530
  }
435
531
  if (trimmed === "!help") {
532
+ const extraCommands = session ? `
533
+ !archive - archive this thread and end the session` : "";
436
534
  return {
437
535
  handled: true,
438
536
  response: [
@@ -443,35 +541,49 @@ async function handleCommand(input, agentService, promptQueue) {
443
541
  "!compact - compact the persistent session",
444
542
  "!reset-session - start a fresh persistent session",
445
543
  "!reload - reload resources (AGENTS.md, extensions, skills, etc.)",
446
- "Any other DM text goes to the persistent agent session."
447
- ].join(`
544
+ extraCommands,
545
+ "Any other text goes to the agent session."
546
+ ].filter(Boolean).join(`
448
547
  `)
449
548
  };
450
549
  }
550
+ if (trimmed === "!archive") {
551
+ if (!session) {
552
+ return {
553
+ handled: true,
554
+ response: "!archive is only available in forum threads."
555
+ };
556
+ }
557
+ return {
558
+ handled: true,
559
+ archive: true,
560
+ response: "Archiving thread and shutting down session."
561
+ };
562
+ }
451
563
  if (trimmed === "!status") {
452
- const agentStatus = agentService.getStatus();
453
- const queueStatus = promptQueue.getSnapshot();
454
- const contextUsage = agentStatus.contextUsage;
455
- const contextLine = contextUsage ? contextUsage.tokens === null || contextUsage.percent === null ? `context: ?/${contextUsage.contextWindow}` : `context: ${contextUsage.tokens}/${contextUsage.contextWindow} (${Math.round(contextUsage.percent)}%)` : "context: (unavailable)";
564
+ const effectiveSession = session ?? agentService.getSession();
565
+ if (!effectiveSession) {
566
+ return {
567
+ handled: true,
568
+ response: "No active session."
569
+ };
570
+ }
456
571
  return {
457
572
  handled: true,
458
- response: [
459
- `model: ${agentStatus.model}`,
460
- `session-id: ${agentStatus.sessionId}`,
461
- `session-file: ${agentStatus.sessionFile ?? "(none)"}`,
462
- `streaming: ${agentStatus.streaming}`,
463
- agentStatus.thinkingInfo,
464
- contextLine,
465
- `queue-pending: ${queueStatus.pending}`,
466
- `queue-busy: ${queueStatus.busy}`
467
- ].join(`
468
- `)
573
+ response: getSessionStatusText(effectiveSession, promptQueue)
469
574
  };
470
575
  }
471
576
  if (trimmed === "!thinking" || trimmed.startsWith("!thinking ")) {
577
+ const effectiveSession = session ?? agentService.getSession();
578
+ if (!effectiveSession) {
579
+ return {
580
+ handled: true,
581
+ response: "No active session."
582
+ };
583
+ }
472
584
  const parts = trimmed.split(" ");
473
585
  if (parts.length === 1) {
474
- const info = agentService.getThinkingLevel();
586
+ const info = getThinkingInfo(effectiveSession);
475
587
  if (!info.supported) {
476
588
  return {
477
589
  handled: true,
@@ -489,17 +601,38 @@ async function handleCommand(input, agentService, promptQueue) {
489
601
  };
490
602
  }
491
603
  const requestedLevel = parts[1];
492
- const result = agentService.setThinkingLevel(requestedLevel);
604
+ if (!effectiveSession.supportsThinking()) {
605
+ return {
606
+ handled: true,
607
+ response: "Current model does not support reasoning/thinking."
608
+ };
609
+ }
610
+ const available = effectiveSession.getAvailableThinkingLevels();
611
+ if (!available.includes(requestedLevel)) {
612
+ return {
613
+ handled: true,
614
+ response: `Invalid thinking level "${requestedLevel}" for current model. Available: ${available.join(", ")}`
615
+ };
616
+ }
617
+ effectiveSession.setThinkingLevel(requestedLevel);
493
618
  return {
494
619
  handled: true,
495
- response: result
620
+ response: `Thinking level set to "${requestedLevel}".`
496
621
  };
497
622
  }
498
623
  if (trimmed === "!compact") {
624
+ const effectiveSession = session ?? agentService.getSession();
625
+ if (!effectiveSession) {
626
+ return {
627
+ handled: true,
628
+ response: "No active session."
629
+ };
630
+ }
499
631
  return {
500
632
  handled: true,
501
633
  response: await promptQueue.enqueue(async () => {
502
- return agentService.compact();
634
+ await effectiveSession.compact();
635
+ return `Compaction finished for session ${effectiveSession.sessionId}.`;
503
636
  })
504
637
  };
505
638
  }
@@ -512,6 +645,17 @@ async function handleCommand(input, agentService, promptQueue) {
512
645
  };
513
646
  }
514
647
  if (trimmed === "!reset-session") {
648
+ if (session) {
649
+ return {
650
+ handled: true,
651
+ response: await promptQueue.enqueue(async () => {
652
+ const previousSession = session;
653
+ await previousSession.abort();
654
+ previousSession.dispose();
655
+ return `Session reset. Old session kept at ${previousSession.sessionFile ?? "(unknown path)"}. Use !archive to archive the thread and start fresh.`;
656
+ })
657
+ };
658
+ }
515
659
  return {
516
660
  handled: true,
517
661
  response: await promptQueue.enqueue(async () => {
@@ -556,77 +700,184 @@ function chunkMessage(text, maxChunkSize = SAFE_MESSAGE_LIMIT) {
556
700
  return chunks.map((chunk) => chunk.slice(0, DISCORD_MESSAGE_LIMIT));
557
701
  }
558
702
 
559
- // src/discord-client.ts
560
- async function startDiscordClient(config, agentService, promptQueue) {
703
+ // src/discord-gateway-client.ts
704
+ function resolveScope(message) {
705
+ if (message.channel.type === ChannelType.DM) {
706
+ return "dm";
707
+ }
708
+ if (message.channel.isThread()) {
709
+ return `thread:${message.channel.id}`;
710
+ }
711
+ return null;
712
+ }
713
+ function isAuthorized(message, scope, authConfig) {
714
+ if (scope === "dm") {
715
+ return message.author.id === authConfig.discordAllowedUserId;
716
+ }
717
+ if (scope.startsWith("thread:")) {
718
+ const channel = message.channel;
719
+ if (!channel.isThread()) {
720
+ return false;
721
+ }
722
+ const parentId = channel.parentId;
723
+ if (!parentId || !authConfig.discordAllowedForumChannelIds.includes(parentId)) {
724
+ return false;
725
+ }
726
+ return authConfig.discordAllowedUserIds.includes(message.author.id);
727
+ }
728
+ return false;
729
+ }
730
+ var TYPING_INTERVAL_MS = 8000;
731
+ function startTypingInterval(channel) {
732
+ channel.sendTyping();
733
+ return setInterval(() => {
734
+ channel.sendTyping();
735
+ }, TYPING_INTERVAL_MS);
736
+ }
737
+ function stopTypingInterval(interval) {
738
+ if (interval) {
739
+ clearInterval(interval);
740
+ }
741
+ }
742
+ async function sendReply(message, text) {
743
+ const channel = message.channel;
744
+ if (!channel.isSendable()) {
745
+ console.log("[gateway] reply skipped, channel not sendable", {
746
+ messageId: message.id
747
+ });
748
+ return;
749
+ }
750
+ const chunks = chunkMessage(text);
751
+ console.log("[gateway] sending reply", {
752
+ messageId: message.id,
753
+ chunkCount: chunks.length,
754
+ textLength: text.length
755
+ });
756
+ const [firstChunk, ...remainingChunks] = chunks;
757
+ if (!firstChunk) {
758
+ return;
759
+ }
760
+ await message.reply(firstChunk);
761
+ for (const chunk of remainingChunks) {
762
+ await channel.send(chunk);
763
+ }
764
+ }
765
+ async function startGatewayClient(config, agentService, sessionRegistry, authConfig) {
561
766
  const client = new Client({
562
767
  intents: [
563
768
  GatewayIntentBits.DirectMessages,
769
+ GatewayIntentBits.Guilds,
770
+ GatewayIntentBits.GuildMessages,
564
771
  GatewayIntentBits.MessageContent
565
772
  ],
566
773
  partials: [Partials.Channel]
567
774
  });
568
775
  client.once(Events.ClientReady, async (readyClient) => {
569
- console.log(`[discord] logged in as ${readyClient.user.tag}`);
570
- if (!config.startupMessage) {
776
+ console.log(`[gateway] logged in as ${readyClient.user.tag}`);
777
+ if (!authConfig.startupMessage) {
571
778
  return;
572
779
  }
573
780
  try {
574
- const user = await readyClient.users.fetch(config.discordAllowedUserId);
781
+ const user = await readyClient.users.fetch(authConfig.discordAllowedUserId);
575
782
  const dmChannel = await user.createDM();
576
- await dmChannel.send(config.startupMessage);
577
- console.log("[discord] sent startup dm", {
578
- userId: config.discordAllowedUserId
783
+ await dmChannel.send(authConfig.startupMessage);
784
+ console.log("[gateway] sent startup dm", {
785
+ userId: authConfig.discordAllowedUserId
579
786
  });
580
787
  } catch (error) {
581
- console.error("[discord] failed to send startup dm", error);
788
+ console.error("[gateway] failed to send startup dm", error);
582
789
  }
583
790
  });
584
791
  client.on(Events.MessageCreate, async (message) => {
585
- console.log("[discord] message received", {
792
+ if (message.channel.isThread()) {
793
+ console.log("[gateway:debug] thread message raw", {
794
+ messageId: message.id,
795
+ authorId: message.author.id,
796
+ authorTag: message.author.tag,
797
+ channelId: message.channel.id,
798
+ channelType: message.channel.type,
799
+ parentId: message.channel.parentId,
800
+ parentType: message.channel.parent?.type,
801
+ guildId: message.guild?.id,
802
+ content: message.content.slice(0, 500)
803
+ });
804
+ }
805
+ console.log("[gateway] message received", {
586
806
  messageId: message.id,
587
807
  authorId: message.author.id,
588
808
  channelType: message.channel.type,
589
- content: message.content
809
+ content: message.content.slice(0, 200)
590
810
  });
591
811
  try {
592
- await onMessage(message, config, agentService, promptQueue);
812
+ await onMessage(message, config, agentService, sessionRegistry, authConfig);
593
813
  } catch (error) {
594
- console.error("[discord] message handling failed", error);
814
+ console.error("[gateway] message handling failed", error);
595
815
  await sendReply(message, "The bot hit an error while handling that message.");
596
816
  }
597
817
  });
818
+ client.on(Events.ThreadDelete, async (thread) => {
819
+ const scope = `thread:${thread.id}`;
820
+ console.log("[gateway] thread deleted", { threadId: thread.id, scope });
821
+ await sessionRegistry.remove(scope);
822
+ });
598
823
  await client.login(config.discordBotToken);
599
824
  return client;
600
825
  }
601
- async function onMessage(message, config, agentService, promptQueue) {
826
+ async function onMessage(message, config, agentService, sessionRegistry, authConfig) {
602
827
  if (message.author.bot) {
603
- console.log("[discord] ignored bot message", { messageId: message.id });
828
+ console.log("[gateway] ignored bot message", { messageId: message.id });
604
829
  return;
605
830
  }
606
- if (message.author.id !== config.discordAllowedUserId) {
607
- console.log("[discord] ignored unauthorized user", {
831
+ const scope = resolveScope(message);
832
+ if (scope === null) {
833
+ console.log("[gateway] unsupported channel type, ignoring", {
608
834
  messageId: message.id,
609
- authorId: message.author.id
835
+ channelType: message.channel.type
610
836
  });
611
837
  return;
612
838
  }
613
- if (message.channel.type !== ChannelType.DM) {
614
- console.log("[discord] ignored non-dm message", {
839
+ if (!isAuthorized(message, scope, authConfig)) {
840
+ console.log("[gateway] unauthorized", {
615
841
  messageId: message.id,
616
- channelType: message.channel.type
842
+ authorId: message.author.id,
843
+ scope
617
844
  });
618
845
  return;
619
846
  }
620
847
  const content = message.content.trim();
621
848
  if (!content) {
622
- console.log("[discord] ignored empty message", { messageId: message.id });
849
+ console.log("[gateway] ignored empty message", { messageId: message.id });
623
850
  return;
624
851
  }
625
- const typingInterval = await startTypingInterval(message.channel);
626
- const commandResult = await handleCommand(content, agentService, promptQueue);
852
+ const { session, promptQueue } = await sessionRegistry.getOrCreate(scope);
853
+ let typingInterval = null;
854
+ if (message.channel.isSendable()) {
855
+ typingInterval = startTypingInterval(message.channel);
856
+ }
857
+ const commandResult = await handleCommand(content, {
858
+ agentService,
859
+ promptQueue,
860
+ session
861
+ });
627
862
  if (commandResult.handled) {
628
863
  stopTypingInterval(typingInterval);
629
- console.log("[discord] command handled", {
864
+ if (commandResult.archive && scope.startsWith("thread:")) {
865
+ console.log("[gateway] archiving thread", { scope });
866
+ const archiveChannel = message.channel;
867
+ if (archiveChannel.isSendable()) {
868
+ await archiveChannel.send(commandResult.response ?? "Archiving...");
869
+ }
870
+ try {
871
+ if (archiveChannel.isThread()) {
872
+ await archiveChannel.setArchived(true);
873
+ }
874
+ } catch (error) {
875
+ console.error("[gateway] failed to archive thread", error);
876
+ }
877
+ await sessionRegistry.remove(scope);
878
+ return;
879
+ }
880
+ console.log("[gateway] command handled", {
630
881
  messageId: message.id,
631
882
  command: content,
632
883
  hasResponse: Boolean(commandResult.response)
@@ -638,11 +889,12 @@ async function onMessage(message, config, agentService, promptQueue) {
638
889
  }
639
890
  if (!message.channel.isSendable()) {
640
891
  stopTypingInterval(typingInterval);
641
- console.log("[discord] channel is not sendable", { messageId: message.id });
892
+ console.log("[gateway] channel not sendable", { messageId: message.id });
642
893
  return;
643
894
  }
644
895
  const queuePosition = promptQueue.getSnapshot().pending;
645
896
  console.log("[queue] enqueue request", {
897
+ scope,
646
898
  messageId: message.id,
647
899
  queuePosition
648
900
  });
@@ -650,49 +902,24 @@ async function onMessage(message, config, agentService, promptQueue) {
650
902
  await sendReply(message, `Queued. ${queuePosition} request(s) ahead of this one.`);
651
903
  }
652
904
  const response = await promptQueue.enqueue(async () => {
653
- console.log(`[queue] processing message ${message.id}`);
654
- return agentService.prompt(content);
905
+ console.log(`[queue] processing message ${message.id} in scope ${scope}`);
906
+ const transformedPrompt = await config.promptTransform(content);
907
+ return collectReply(session, transformedPrompt, {
908
+ logPrefix: `[agent:${session.sessionId}]`
909
+ });
655
910
  });
656
911
  stopTypingInterval(typingInterval);
657
- console.log("[discord] response ready", {
912
+ console.log("[gateway] response ready", {
913
+ scope,
658
914
  messageId: message.id,
659
915
  responseLength: response.length,
660
916
  preview: response.slice(0, 200)
661
917
  });
662
918
  await sendReply(message, response);
663
919
  }
664
- async function sendReply(message, text) {
665
- if (!message.channel.isSendable()) {
666
- console.log("[discord] reply skipped, channel not sendable", {
667
- messageId: message.id
668
- });
669
- return;
670
- }
671
- const chunks = chunkMessage(text);
672
- console.log("[discord] sending reply", {
673
- messageId: message.id,
674
- chunkCount: chunks.length,
675
- textLength: text.length
676
- });
677
- const [firstChunk, ...remainingChunks] = chunks;
678
- if (!firstChunk) {
679
- return;
680
- }
681
- await message.reply(firstChunk);
682
- for (const chunk of remainingChunks) {
683
- await message.channel.send(chunk);
684
- }
685
- }
686
- var TYPING_INTERVAL_MS = 8000;
687
- function startTypingInterval(channel) {
688
- channel.sendTyping();
689
- return setInterval(() => {
690
- channel.sendTyping();
691
- }, TYPING_INTERVAL_MS);
692
- }
693
- function stopTypingInterval(interval) {
694
- clearInterval(interval);
695
- }
920
+
921
+ // src/session-registry.ts
922
+ import path3 from "node:path";
696
923
 
697
924
  // src/prompt-queue.ts
698
925
  class PromptQueue {
@@ -732,6 +959,70 @@ class PromptQueue {
732
959
  }
733
960
  }
734
961
 
962
+ // src/session-registry.ts
963
+ function sessionDirForScope(agentDir, scope) {
964
+ if (scope === "dm") {
965
+ return path3.join(agentDir, "sessions");
966
+ }
967
+ if (scope.startsWith("thread:")) {
968
+ const threadId = scope.slice(7);
969
+ return path3.join(agentDir, "sessions", `thread-${threadId}`);
970
+ }
971
+ throw new Error(`Unknown session scope: ${scope}`);
972
+ }
973
+
974
+ class SessionRegistry {
975
+ scopes = new Map;
976
+ agentService;
977
+ constructor(agentService) {
978
+ this.agentService = agentService;
979
+ }
980
+ async getOrCreate(scope) {
981
+ const existing = this.scopes.get(scope);
982
+ if (existing) {
983
+ return existing;
984
+ }
985
+ const sessionDir = sessionDirForScope(this.agentService.getAgentDir(), scope);
986
+ const session = await this.agentService.createSession(sessionDir);
987
+ const promptQueue = new PromptQueue;
988
+ const entry = {
989
+ session,
990
+ promptQueue,
991
+ createdAt: new Date
992
+ };
993
+ this.scopes.set(scope, entry);
994
+ console.log("[session-registry] scope registered", {
995
+ scope,
996
+ sessionDir,
997
+ sessionId: session.sessionId
998
+ });
999
+ return entry;
1000
+ }
1001
+ async remove(scope) {
1002
+ const entry = this.scopes.get(scope);
1003
+ if (!entry) {
1004
+ return;
1005
+ }
1006
+ console.log("[session-registry] removing scope", { scope });
1007
+ await entry.session.abort();
1008
+ entry.session.dispose();
1009
+ this.scopes.delete(scope);
1010
+ }
1011
+ get(scope) {
1012
+ return this.scopes.get(scope);
1013
+ }
1014
+ getScopes() {
1015
+ return Array.from(this.scopes.keys());
1016
+ }
1017
+ async shutdownAll() {
1018
+ console.log("[session-registry] shutting down all scopes", this.scopes.size);
1019
+ const scopes = Array.from(this.scopes.keys());
1020
+ for (const scope of scopes) {
1021
+ await this.remove(scope);
1022
+ }
1023
+ }
1024
+ }
1025
+
735
1026
  // src/prompt-context.ts
736
1027
  function buildTimeContextPrompt(userMessage, options = {}) {
737
1028
  const timeZone = options.timeZone || "UTC";
@@ -753,15 +1044,21 @@ function buildTimeContextPrompt(userMessage, options = {}) {
753
1044
  }
754
1045
 
755
1046
  // src/index.ts
756
- async function startDiscordPiBridge(config) {
757
- const resolvedConfig = resolveConfig(config);
1047
+ async function startDiscordGateway(config) {
1048
+ const resolvedConfig = resolveGatewayConfig(config);
758
1049
  const agentService = new AgentService(resolvedConfig);
759
- const promptQueue = new PromptQueue;
760
- console.log("[boot] initializing persistent agent session");
1050
+ console.log("[gateway] initializing agent service");
761
1051
  await agentService.initialize();
762
- console.log("[boot] agent ready", agentService.getStatus());
763
- const client = await startDiscordClient(resolvedConfig, agentService, promptQueue);
764
- const stop = createStopHandler(client, agentService, resolvedConfig);
1052
+ console.log("[gateway] agent ready", agentService.getStatus());
1053
+ const authConfig = {
1054
+ discordAllowedUserId: resolvedConfig.discordAllowedUserId,
1055
+ discordAllowedForumChannelIds: resolvedConfig.discordAllowedForumChannelIds,
1056
+ discordAllowedUserIds: resolvedConfig.discordAllowedUserIds,
1057
+ startupMessage: resolvedConfig.startupMessage
1058
+ };
1059
+ const sessionRegistry = new SessionRegistry(agentService);
1060
+ const client = await startGatewayClient(resolvedConfig, agentService, sessionRegistry, authConfig);
1061
+ const stop = createGatewayStopHandler(client, agentService, sessionRegistry, resolvedConfig);
765
1062
  if (resolvedConfig.shutdownOnSignals) {
766
1063
  registerSignalHandlers(stop);
767
1064
  }
@@ -773,18 +1070,22 @@ async function startDiscordPiBridge(config) {
773
1070
  }
774
1071
  };
775
1072
  }
776
- function createStopHandler(client, agentService, config) {
1073
+ async function startDiscordPiBridge(config) {
1074
+ return startDiscordGateway(config);
1075
+ }
1076
+ function createGatewayStopHandler(client, agentService, sessionRegistry, config) {
777
1077
  let stopped = false;
778
1078
  return async () => {
779
1079
  if (stopped) {
780
1080
  return;
781
1081
  }
782
1082
  stopped = true;
783
- console.log("[shutdown] stopping discord pi bridge", {
1083
+ console.log("[shutdown] stopping discord gateway", {
784
1084
  cwd: config.cwd,
785
1085
  agentDir: config.agentDir
786
1086
  });
787
1087
  client.destroy();
1088
+ await sessionRegistry.shutdownAll();
788
1089
  await agentService.shutdown();
789
1090
  };
790
1091
  }
@@ -805,7 +1106,9 @@ function registerSignalHandlers(stop) {
805
1106
  }
806
1107
  export {
807
1108
  startDiscordPiBridge,
1109
+ startDiscordGateway,
808
1110
  resolveConfig,
809
1111
  loadDiscordPiBridgeConfigFromEnv,
1112
+ loadDiscordGatewayConfigFromEnv,
810
1113
  buildTimeContextPrompt
811
1114
  };
@@ -0,0 +1,26 @@
1
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
2
+ import type { AgentService } from "./agent-service";
3
+ import { PromptQueue } from "./prompt-queue";
4
+ export type SessionScope = string;
5
+ export type ScopeEntry = {
6
+ session: AgentSession;
7
+ promptQueue: PromptQueue;
8
+ createdAt: Date;
9
+ };
10
+ /**
11
+ * Derive a deterministic session directory from a scope key.
12
+ *
13
+ * "dm" → <agentDir>/sessions
14
+ * "thread:<id>" → <agentDir>/sessions/thread-<id>
15
+ */
16
+ export declare function sessionDirForScope(agentDir: string, scope: SessionScope): string;
17
+ export declare class SessionRegistry {
18
+ private readonly scopes;
19
+ private readonly agentService;
20
+ constructor(agentService: AgentService);
21
+ getOrCreate(scope: SessionScope): Promise<ScopeEntry>;
22
+ remove(scope: SessionScope): Promise<void>;
23
+ get(scope: SessionScope): ScopeEntry | undefined;
24
+ getScopes(): SessionScope[];
25
+ shutdownAll(): Promise<void>;
26
+ }
package/dist/types.d.ts CHANGED
@@ -43,3 +43,21 @@ export type DiscordPiBridge = {
43
43
  stop: () => Promise<void>;
44
44
  getStatus: () => AgentStatus;
45
45
  };
46
+ export type DiscordGatewayConfig = DiscordPiBridgeConfig & {
47
+ /** Which forum channels the bot responds in (absent = forum disabled). */
48
+ discordAllowedForumChannelIds?: string[];
49
+ /** Which users can interact in forum threads (defaults to [discordAllowedUserId]). */
50
+ discordAllowedUserIds?: string[];
51
+ /** Auto-shutdown idle thread sessions after this many ms. */
52
+ sessionIdleTimeoutMs?: number;
53
+ };
54
+ export type ResolvedDiscordGatewayConfig = ResolvedDiscordPiBridgeConfig & {
55
+ discordAllowedForumChannelIds: string[];
56
+ discordAllowedUserIds: string[];
57
+ sessionIdleTimeoutMs: number | null;
58
+ };
59
+ export type DiscordGateway = {
60
+ client: Client;
61
+ stop: () => Promise<void>;
62
+ getStatus: () => AgentStatus;
63
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@friendlyrobot/discord-pi-agent",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "description": "Reusable Discord gateway bridge for persistent pi agent sessions",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,10 +27,13 @@
27
27
  "scripts": {
28
28
  "test:watch": "vitest",
29
29
  "test": "vitest run",
30
- "update-deps": "bun add @mariozechner/pi-ai@latest @mariozechner/pi-coding-agent@latest marked@latest discord.js@latest dotenv@latest prettier@latest; bun add -d @types/node@latest typescript@latest vitest@latest @vitest/ui@latest",
30
+ "update-deps": "npx npm-check-updates",
31
31
  "format": "prettier --write .",
32
- "build": "rm -rf dist && bun build ./src/index.ts --outdir ./dist --target node --format esm --packages external && tsc -p tsconfig.json --emitDeclarationOnly --declaration --declarationMap false",
33
- "typecheck": "tsc --noEmit -p tsconfig.json"
32
+ "build:01-clean": "rm -rf dist",
33
+ "build:02-tsgo": "tsgo -p tsconfig.json --emitDeclarationOnly --declaration --declarationMap false",
34
+ "build:03-build": "bun build ./src/index.ts --outdir ./dist --target node --format esm --packages external",
35
+ "build": "bun run --sequential 'build:*'",
36
+ "typecheck": "tsgo --noEmit -p tsconfig.json"
34
37
  },
35
38
  "dependencies": {
36
39
  "@mariozechner/pi-ai": "^0.70.2",
@@ -42,8 +45,8 @@
42
45
  },
43
46
  "devDependencies": {
44
47
  "@types/node": "^25.6.0",
48
+ "@typescript/native-preview": "^7.0.0-dev.20260424.2",
45
49
  "@vitest/ui": "^4.1.5",
46
- "typescript": "^6.0.3",
47
50
  "vitest": "^4.1.5"
48
51
  }
49
52
  }