@nordbyte/nordrelay 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +35 -0
  2. package/README.md +109 -49
  3. package/dist/activity-events.js +2 -2
  4. package/dist/adapter-conformance.js +61 -0
  5. package/dist/bot.js +18 -31
  6. package/dist/channel-adapter.js +33 -6
  7. package/dist/channel-command-catalog.js +6 -0
  8. package/dist/channel-command-core.js +60 -0
  9. package/dist/channel-command-service.js +20 -4
  10. package/dist/channel-mirror-registry.js +9 -2
  11. package/dist/channel-prompt-engine.js +177 -0
  12. package/dist/channel-turn-lifecycle.js +73 -0
  13. package/dist/config-metadata.js +67 -8
  14. package/dist/config.js +48 -1
  15. package/dist/context-key.js +32 -0
  16. package/dist/discord-bot.js +99 -327
  17. package/dist/index.js +9 -0
  18. package/dist/metrics.js +2 -0
  19. package/dist/peer-client.js +33 -1
  20. package/dist/peer-readiness.js +77 -0
  21. package/dist/peer-runtime-service.js +22 -0
  22. package/dist/peer-store.js +13 -0
  23. package/dist/relay-runtime-helpers.js +3 -1
  24. package/dist/relay-runtime.js +7 -0
  25. package/dist/settings-wizard-test.js +216 -0
  26. package/dist/slack-artifacts.js +165 -0
  27. package/dist/slack-bot.js +1461 -0
  28. package/dist/slack-channel-runtime.js +147 -0
  29. package/dist/slack-command-surface.js +46 -0
  30. package/dist/slack-diagnostics.js +116 -0
  31. package/dist/slack-rate-limit.js +139 -0
  32. package/dist/user-management-crypto.js +38 -0
  33. package/dist/user-management-normalize.js +188 -0
  34. package/dist/user-management-types.js +1 -0
  35. package/dist/user-management.js +193 -196
  36. package/dist/web-api-contract.js +8 -0
  37. package/dist/web-dashboard-access-routes.js +62 -0
  38. package/dist/web-dashboard-assets.js +1 -0
  39. package/dist/web-dashboard-pages.js +14 -4
  40. package/dist/web-dashboard-peer-routes.js +32 -11
  41. package/dist/web-dashboard.js +34 -0
  42. package/dist/web-state.js +2 -2
  43. package/dist/webui-assets/dashboard.css +193 -0
  44. package/dist/webui-assets/dashboard.js +544 -144
  45. package/package.json +3 -1
  46. package/plugins/nordrelay/scripts/nordrelay.mjs +101 -10
package/dist/bot.js CHANGED
@@ -14,6 +14,7 @@ import { renderAgentUpdateJobAction } from "./channel-actions.js";
14
14
  import { ChannelCommandService } from "./channel-command-service.js";
15
15
  import { runChannelPeerPrompt } from "./channel-peer-prompt.js";
16
16
  import { deliverChannelAction } from "./channel-runtime.js";
17
+ import { createChannelTurnLifecycle, createChannelTypingLoop } from "./channel-turn-lifecycle.js";
17
18
  import { agentLabel, agentReasoningLabel, agentReasoningOptions, } from "./agent.js";
18
19
  import { getExternalActivityForSession, getExternalSnapshotForSession, } from "./agent-activity.js";
19
20
  import { checkAuthStatus, clearAuthCache, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
@@ -1118,14 +1119,8 @@ export function createBot(config, registry) {
1118
1119
  }
1119
1120
  const busyState = getBusyState(contextKey);
1120
1121
  busyState.processing = true;
1121
- const progress = {
1122
- status: "running",
1123
- promptDescription: envelope.description,
1124
- startedAt: Date.now(),
1125
- updatedAt: Date.now(),
1126
- toolCounts: new Map(),
1127
- textCharacters: 0,
1128
- };
1122
+ const turnLifecycle = createChannelTurnLifecycle(envelope.description);
1123
+ const progress = turnLifecycle.progress;
1129
1124
  turnProgress.set(contextKey, progress);
1130
1125
  const abortKeyboard = new InlineKeyboard().text("⏹ Abort", `agent_abort:${contextKey}`);
1131
1126
  const toolVerbosity = config.toolVerbosity;
@@ -1147,12 +1142,13 @@ export function createBot(config, registry) {
1147
1142
  let promptStartedAt;
1148
1143
  const toolActivityNames = new Map();
1149
1144
  const toolActivityStartedAt = new Map();
1150
- const typingInterval = setInterval(() => {
1151
- void sendChatActionSafe(bot.api, chatId, "typing", messageThreadId).catch(() => { });
1152
- }, TYPING_INTERVAL_MS);
1153
- void sendChatActionSafe(bot.api, chatId, "typing", messageThreadId).catch(() => { });
1145
+ const typingLoop = createChannelTypingLoop({
1146
+ intervalMs: TYPING_INTERVAL_MS,
1147
+ sendTyping: () => sendChatActionSafe(bot.api, chatId, "typing", messageThreadId),
1148
+ });
1149
+ typingLoop.start();
1154
1150
  const stopTyping = () => {
1155
- clearInterval(typingInterval);
1151
+ typingLoop.stop();
1156
1152
  };
1157
1153
  const clearFlushTimer = () => {
1158
1154
  if (flushTimer) {
@@ -1303,6 +1299,7 @@ export function createBot(config, registry) {
1303
1299
  return;
1304
1300
  }
1305
1301
  finalized = true;
1302
+ turnLifecycle.recordCompleted();
1306
1303
  stopTyping();
1307
1304
  clearFlushTimer();
1308
1305
  if (responseMessagePromise) {
@@ -1331,8 +1328,7 @@ export function createBot(config, registry) {
1331
1328
  const callbacks = {
1332
1329
  onTextDelta: (delta) => {
1333
1330
  accumulatedText += delta;
1334
- progress.textCharacters += delta.length;
1335
- progress.updatedAt = Date.now();
1331
+ turnLifecycle.recordTextDelta(delta.length);
1336
1332
  if (!responseMessageId) {
1337
1333
  void ensureResponseMessage()
1338
1334
  .then(() => {
@@ -1346,10 +1342,7 @@ export function createBot(config, registry) {
1346
1342
  scheduleFlush();
1347
1343
  },
1348
1344
  onToolStart: (toolName, toolCallId) => {
1349
- progress.currentTool = toolName;
1350
- progress.lastTool = toolName;
1351
- progress.updatedAt = Date.now();
1352
- progress.toolCounts.set(toolName, (progress.toolCounts.get(toolName) ?? 0) + 1);
1345
+ turnLifecycle.recordToolStart(toolName);
1353
1346
  toolActivityNames.set(toolCallId, toolName);
1354
1347
  toolActivityStartedAt.set(toolCallId, Date.now());
1355
1348
  appendTelegramActivity(ctx, contextKey, session, {
@@ -1393,7 +1386,7 @@ export function createBot(config, registry) {
1393
1386
  });
1394
1387
  },
1395
1388
  onToolUpdate: (toolCallId, partialResult) => {
1396
- progress.updatedAt = Date.now();
1389
+ turnLifecycle.recordToolUpdate();
1397
1390
  if (toolVerbosity === "none" || toolVerbosity === "summary") {
1398
1391
  return;
1399
1392
  }
@@ -1404,8 +1397,7 @@ export function createBot(config, registry) {
1404
1397
  state.partialResult = appendWithCap(state.partialResult, partialResult, TOOL_OUTPUT_PREVIEW_LIMIT);
1405
1398
  },
1406
1399
  onToolEnd: (toolCallId, isError) => {
1407
- progress.currentTool = undefined;
1408
- progress.updatedAt = Date.now();
1400
+ turnLifecycle.recordToolEnd();
1409
1401
  const activityToolName = toolActivityNames.get(toolCallId) ?? "tool";
1410
1402
  const activityStartedAt = toolActivityStartedAt.get(toolCallId);
1411
1403
  appendTelegramActivity(ctx, contextKey, session, {
@@ -1450,7 +1442,7 @@ export function createBot(config, registry) {
1450
1442
  });
1451
1443
  },
1452
1444
  onTodoUpdate: (items) => {
1453
- progress.updatedAt = Date.now();
1445
+ turnLifecycle.touch();
1454
1446
  if (toolVerbosity === "none") {
1455
1447
  return;
1456
1448
  }
@@ -1482,7 +1474,7 @@ export function createBot(config, registry) {
1482
1474
  },
1483
1475
  onTurnComplete: (usage) => {
1484
1476
  lastTurnUsage = usage;
1485
- progress.updatedAt = Date.now();
1477
+ turnLifecycle.touch();
1486
1478
  },
1487
1479
  onAgentEnd: () => {
1488
1480
  void finalizeResponse().catch((error) => {
@@ -1597,9 +1589,7 @@ export function createBot(config, registry) {
1597
1589
  await pruneArtifacts(session.getInfo().workspace);
1598
1590
  }
1599
1591
  }
1600
- progress.status = "completed";
1601
- progress.completedAt = Date.now();
1602
- progress.updatedAt = progress.completedAt;
1592
+ turnLifecycle.recordCompleted();
1603
1593
  auditContext(ctx, contextKey, session, {
1604
1594
  action: "prompt_completed",
1605
1595
  status: "ok",
@@ -1614,8 +1604,7 @@ export function createBot(config, registry) {
1614
1604
  });
1615
1605
  }
1616
1606
  catch (error) {
1617
- progress.status = "failed";
1618
- progress.error = friendlyErrorText(error);
1607
+ turnLifecycle.recordFailed(friendlyErrorText(error));
1619
1608
  auditContext(ctx, contextKey, session, {
1620
1609
  action: "prompt_failed",
1621
1610
  status: "failed",
@@ -1632,8 +1621,6 @@ export function createBot(config, registry) {
1632
1621
  durationMs: Date.now() - promptStartedAt,
1633
1622
  });
1634
1623
  }
1635
- progress.completedAt = Date.now();
1636
- progress.updatedAt = progress.completedAt;
1637
1624
  stopTyping();
1638
1625
  clearFlushTimer();
1639
1626
  if (responseMessagePromise) {
@@ -19,6 +19,17 @@ const DISCORD_CAPABILITIES = [
19
19
  "voice",
20
20
  "topics",
21
21
  ];
22
+ const SLACK_CAPABILITIES = [
23
+ "text",
24
+ "streaming-edits",
25
+ "typing",
26
+ "inline-buttons",
27
+ "files",
28
+ "photos",
29
+ "voice",
30
+ "topics",
31
+ "webhooks",
32
+ ];
22
33
  const PLANNED_CHANNELS = [
23
34
  {
24
35
  id: "whatsapp",
@@ -27,12 +38,6 @@ const PLANNED_CHANNELS = [
27
38
  status: "planned",
28
39
  notes: "Requires a WhatsApp Business provider integration.",
29
40
  },
30
- {
31
- id: "slack",
32
- label: "Slack",
33
- capabilities: ["text", "streaming-edits", "typing", "inline-buttons", "files"],
34
- status: "planned",
35
- },
36
41
  {
37
42
  id: "matrix",
38
43
  label: "Matrix",
@@ -82,10 +87,32 @@ export class DiscordChannelAdapter {
82
87
  };
83
88
  }
84
89
  }
90
+ export class SlackChannelAdapter {
91
+ id = "slack";
92
+ label = "Slack";
93
+ capabilities = new Set(SLACK_CAPABILITIES);
94
+ describe() {
95
+ const requested = process.env.SLACK_ENABLED === "true";
96
+ const enabled = requested && Boolean(process.env.SLACK_BOT_TOKEN) && Boolean(process.env.SLACK_APP_TOKEN);
97
+ return {
98
+ id: this.id,
99
+ label: this.label,
100
+ capabilities: [...this.capabilities],
101
+ status: "available",
102
+ enabled,
103
+ notes: enabled
104
+ ? "Slack bot runtime is enabled."
105
+ : requested
106
+ ? "Slack bot runtime is disabled because SLACK_BOT_TOKEN or SLACK_APP_TOKEN is missing."
107
+ : "Enable with SLACK_ENABLED=true, SLACK_BOT_TOKEN, and SLACK_APP_TOKEN.",
108
+ };
109
+ }
110
+ }
85
111
  export function listChannelDescriptors() {
86
112
  return [
87
113
  new TelegramChannelAdapter().describe(),
88
114
  new DiscordChannelAdapter().describe(),
115
+ new SlackChannelAdapter().describe(),
89
116
  ...PLANNED_CHANNELS,
90
117
  ];
91
118
  }
@@ -86,3 +86,9 @@ export function discordHelpCommandList() {
86
86
  .map((entry) => `/${entry.name}`)
87
87
  .join(", ");
88
88
  }
89
+ export function slackHelpCommandList() {
90
+ return CHANNEL_COMMANDS
91
+ .filter((entry) => entry.slack !== false && !["start", "help", "prompt"].includes(entry.name))
92
+ .map((entry) => `/${entry.name}`)
93
+ .join(", ");
94
+ }
@@ -0,0 +1,60 @@
1
+ import { CHANNEL_COMMANDS } from "./channel-command-catalog.js";
2
+ import { normalizeChannelCommandName } from "./channel-runtime.js";
3
+ export function createSharedChannelCommandDispatcher(input) {
4
+ const handlers = new Map();
5
+ for (const binding of input.bindings) {
6
+ for (const name of binding.names) {
7
+ const normalized = normalizeChannelCommandName(name);
8
+ if (!normalized) {
9
+ throw new Error("Channel command name is required.");
10
+ }
11
+ if (handlers.has(normalized)) {
12
+ throw new Error(`Duplicate ${input.transport} command binding: ${normalized}`);
13
+ }
14
+ handlers.set(normalized, binding.handler);
15
+ }
16
+ }
17
+ return {
18
+ transport: input.transport,
19
+ commandNames: [...handlers.keys()].sort(),
20
+ async dispatch(request, command, argument) {
21
+ const normalized = normalizeChannelCommandName(command);
22
+ const handler = handlers.get(normalized);
23
+ if (!handler) {
24
+ return { matched: false, command: normalized };
25
+ }
26
+ await handler(request, argument, normalized);
27
+ return { matched: true, command: normalized };
28
+ },
29
+ };
30
+ }
31
+ export function channelCatalogCommandNames(transport) {
32
+ return CHANNEL_COMMANDS
33
+ .filter((entry) => {
34
+ if (transport === "telegram")
35
+ return entry.telegram !== false;
36
+ if (transport === "discord")
37
+ return entry.discord !== false;
38
+ return entry.slack !== false;
39
+ })
40
+ .map((entry) => normalizeChannelCommandName(entry.name))
41
+ .sort();
42
+ }
43
+ export function channelCommandCoverage(input) {
44
+ const advertised = new Set(channelCatalogCommandNames(input.transport));
45
+ const implemented = new Set([...input.implemented].map(normalizeChannelCommandName));
46
+ for (const [canonical, aliases] of Object.entries(input.aliases ?? {})) {
47
+ if (!implemented.has(normalizeChannelCommandName(canonical)))
48
+ continue;
49
+ for (const alias of aliases) {
50
+ implemented.add(normalizeChannelCommandName(alias));
51
+ }
52
+ }
53
+ const aliasNames = new Set(Object.values(input.aliases ?? {}).flat().map(normalizeChannelCommandName));
54
+ return {
55
+ advertised: [...advertised].sort(),
56
+ implemented: [...implemented].sort(),
57
+ missing: [...advertised].filter((name) => !implemented.has(name)).sort(),
58
+ extra: [...implemented].filter((name) => !advertised.has(name) && !aliasNames.has(name)).sort(),
59
+ };
60
+ }
@@ -176,7 +176,11 @@ export class ChannelCommandService {
176
176
  });
177
177
  }
178
178
  const mode = this.effectiveMirrorMode(options.source, options.contextKey, options.preferencesStore);
179
- const minInterval = options.source === "telegram" ? this.config.telegramMirrorMinUpdateMs : this.config.discordMirrorMinUpdateMs;
179
+ const minInterval = options.source === "telegram"
180
+ ? this.config.telegramMirrorMinUpdateMs
181
+ : options.source === "discord"
182
+ ? this.config.discordMirrorMinUpdateMs
183
+ : this.config.slackMirrorMinUpdateMs;
180
184
  return {
181
185
  plain: [
182
186
  `CLI mirroring: ${mode}`,
@@ -334,13 +338,25 @@ export class ChannelCommandService {
334
338
  };
335
339
  }
336
340
  defaultMirrorMode(source) {
337
- return source === "telegram" ? this.config.telegramMirrorMode : this.config.discordMirrorMode;
341
+ return source === "telegram"
342
+ ? this.config.telegramMirrorMode
343
+ : source === "discord"
344
+ ? this.config.discordMirrorMode
345
+ : this.config.slackMirrorMode;
338
346
  }
339
347
  defaultNotifyMode(source) {
340
- return source === "telegram" ? this.config.telegramNotifyMode : this.config.discordNotifyMode;
348
+ return source === "telegram"
349
+ ? this.config.telegramNotifyMode
350
+ : source === "discord"
351
+ ? this.config.discordNotifyMode
352
+ : this.config.slackNotifyMode;
341
353
  }
342
354
  defaultQuietHours(source) {
343
- return source === "telegram" ? this.config.telegramQuietHours : this.config.discordQuietHours;
355
+ return source === "telegram"
356
+ ? this.config.telegramQuietHours
357
+ : source === "discord"
358
+ ? this.config.discordQuietHours
359
+ : this.config.slackQuietHours;
344
360
  }
345
361
  effectiveMirrorMode(source, contextKey, preferencesStore) {
346
362
  return preferencesStore.get(contextKey).mirrorMode ?? this.defaultMirrorMode(source);
@@ -49,7 +49,11 @@ export class ChannelMirrorRegistry {
49
49
  return mirrors.some((mirror) => mirror.queuePaused) || this.promptStore.isPaused(sourceContextKey);
50
50
  }
51
51
  effectiveMirrorMode(contextKey, source, preferences) {
52
- const configured = source === "telegram" ? this.config.telegramMirrorMode : this.config.discordMirrorMode;
52
+ const configured = source === "telegram"
53
+ ? this.config.telegramMirrorMode
54
+ : source === "discord"
55
+ ? this.config.discordMirrorMode
56
+ : this.config.slackMirrorMode;
53
57
  return preferences.get(contextKey).mirrorMode ?? configured;
54
58
  }
55
59
  snapshot() {
@@ -67,11 +71,14 @@ export function activeSessionSourceForContextKey(contextKey) {
67
71
  if (channelId === "discord") {
68
72
  return "discord";
69
73
  }
74
+ if (channelId === "slack") {
75
+ return "slack";
76
+ }
70
77
  if (channelId === "web") {
71
78
  return "web";
72
79
  }
73
80
  return "cli";
74
81
  }
75
82
  export function isMirrorChannelSource(source) {
76
- return source === "telegram" || source === "discord";
83
+ return source === "telegram" || source === "discord" || source === "slack";
77
84
  }
@@ -0,0 +1,177 @@
1
+ import { createChannelTurnLifecycle, createChannelTypingLoop } from "./channel-turn-lifecycle.js";
2
+ export function createChannelPromptEngine(options) {
3
+ const lifecycle = createChannelTurnLifecycle(options.promptDescription);
4
+ const { progress, startedAt, turnId } = lifecycle;
5
+ let accumulated = "";
6
+ let responseMessageId;
7
+ let planMessageId;
8
+ let flushTimer;
9
+ const typingLoop = createChannelTypingLoop({
10
+ intervalMs: options.typingIntervalMs,
11
+ sendTyping: () => options.runtime.sendTyping(options.context),
12
+ });
13
+ let lastEditAt = 0;
14
+ let running = false;
15
+ let finalized = false;
16
+ const buttons = options.abortAction
17
+ ? [[{ label: "Abort", action: options.abortAction }]]
18
+ : undefined;
19
+ const clearFlushTimer = () => {
20
+ if (flushTimer) {
21
+ clearTimeout(flushTimer);
22
+ flushTimer = undefined;
23
+ }
24
+ };
25
+ const ensureResponse = async () => {
26
+ if (responseMessageId) {
27
+ return;
28
+ }
29
+ const preview = options.trimMessage(accumulated || "Working...");
30
+ const sent = await options.runtime.sendMessage(options.context, {
31
+ text: preview,
32
+ fallbackText: preview,
33
+ buttons,
34
+ });
35
+ responseMessageId = sent.messageId;
36
+ options.onResponseMessage?.(responseMessageId);
37
+ lastEditAt = Date.now();
38
+ };
39
+ const flushResponse = async (force = false) => {
40
+ if (!accumulated.trim()) {
41
+ return;
42
+ }
43
+ await ensureResponse();
44
+ if (!responseMessageId) {
45
+ return;
46
+ }
47
+ const now = Date.now();
48
+ if (!force && now - lastEditAt < options.editDebounceMs) {
49
+ return;
50
+ }
51
+ const rendered = options.trimMessage(accumulated);
52
+ await options.runtime.editMessage(options.context, responseMessageId, {
53
+ text: rendered,
54
+ fallbackText: rendered,
55
+ buttons,
56
+ });
57
+ lastEditAt = Date.now();
58
+ };
59
+ const scheduleFlush = () => {
60
+ if (flushTimer || !running) {
61
+ return;
62
+ }
63
+ const delay = Math.max(0, options.editDebounceMs - (Date.now() - lastEditAt));
64
+ flushTimer = setTimeout(() => {
65
+ flushTimer = undefined;
66
+ void flushResponse().catch((error) => console.error(`Failed to edit ${options.logPrefix} response:`, error));
67
+ }, delay);
68
+ flushTimer.unref?.();
69
+ };
70
+ const stop = () => {
71
+ running = false;
72
+ typingLoop.stop();
73
+ clearFlushTimer();
74
+ };
75
+ const finalize = async () => {
76
+ if (finalized) {
77
+ return;
78
+ }
79
+ finalized = true;
80
+ lifecycle.recordCompleted();
81
+ stop();
82
+ const finalText = accumulated.trim() || "Done.";
83
+ const chunks = options.splitMessage(finalText);
84
+ if (responseMessageId) {
85
+ const [first, ...rest] = chunks;
86
+ await options.runtime.editMessage(options.context, responseMessageId, {
87
+ text: first ?? "Done.",
88
+ fallbackText: first ?? "Done.",
89
+ });
90
+ for (const chunk of rest) {
91
+ await options.runtime.sendMessage(options.context, { text: chunk, fallbackText: chunk });
92
+ }
93
+ return;
94
+ }
95
+ for (const chunk of chunks) {
96
+ await options.runtime.sendMessage(options.context, { text: chunk, fallbackText: chunk });
97
+ }
98
+ };
99
+ const fail = async (text) => {
100
+ finalized = true;
101
+ lifecycle.recordFailed(text);
102
+ stop();
103
+ const rendered = options.trimMessage(text);
104
+ if (responseMessageId) {
105
+ await options.runtime.editMessage(options.context, responseMessageId, {
106
+ text: rendered,
107
+ fallbackText: rendered,
108
+ }).catch(() => { });
109
+ return;
110
+ }
111
+ await options.runtime.sendMessage(options.context, {
112
+ text: rendered,
113
+ fallbackText: rendered,
114
+ }).catch(() => { });
115
+ };
116
+ const callbacks = {
117
+ onTextDelta: (delta) => {
118
+ accumulated += delta;
119
+ lifecycle.recordTextDelta(delta.length);
120
+ void ensureResponse()
121
+ .then(() => scheduleFlush())
122
+ .catch((error) => console.error(`Failed to send ${options.logPrefix} response:`, error));
123
+ },
124
+ onToolStart: (toolName) => {
125
+ lifecycle.recordToolStart(toolName);
126
+ options.onToolStart?.(toolName);
127
+ if (options.toolVerbosity === "all") {
128
+ const text = `Tool started: ${toolName}`;
129
+ void options.runtime.sendMessage(options.context, { text, fallbackText: text }).catch(() => { });
130
+ }
131
+ },
132
+ onToolUpdate: () => {
133
+ lifecycle.recordToolUpdate();
134
+ },
135
+ onToolEnd: (_toolCallId, isError) => {
136
+ lifecycle.recordToolEnd();
137
+ options.onToolEnd?.(isError);
138
+ },
139
+ onTodoUpdate: (items) => {
140
+ lifecycle.touch();
141
+ const text = [
142
+ "Plan:",
143
+ ...items.map((item) => `${item.completed ? "[x]" : "[ ]"} ${item.text}`),
144
+ ].join("\n");
145
+ if (!planMessageId) {
146
+ void options.runtime.sendMessage(options.context, { text, fallbackText: text }).then((result) => {
147
+ planMessageId = result.messageId;
148
+ }).catch(() => { });
149
+ }
150
+ else {
151
+ void options.runtime.editMessage(options.context, planMessageId, { text, fallbackText: text }).catch(() => { });
152
+ }
153
+ },
154
+ onTurnComplete: () => { },
155
+ onAgentEnd: () => {
156
+ lifecycle.recordCompleted();
157
+ void finalize().catch((error) => console.error(`Failed to finalize ${options.logPrefix} response:`, error));
158
+ },
159
+ };
160
+ return {
161
+ turnId,
162
+ startedAt,
163
+ progress,
164
+ callbacks,
165
+ accumulatedText: () => accumulated,
166
+ start: () => {
167
+ if (running) {
168
+ return;
169
+ }
170
+ running = true;
171
+ typingLoop.start();
172
+ },
173
+ stop,
174
+ finalize,
175
+ fail,
176
+ };
177
+ }
@@ -0,0 +1,73 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export function createChannelTurnLifecycle(promptDescription) {
3
+ const startedAt = Date.now();
4
+ const turnId = randomUUID().slice(0, 12);
5
+ const progress = {
6
+ status: "running",
7
+ promptDescription,
8
+ startedAt,
9
+ updatedAt: startedAt,
10
+ toolCounts: new Map(),
11
+ textCharacters: 0,
12
+ };
13
+ const touch = () => {
14
+ progress.updatedAt = Date.now();
15
+ };
16
+ return {
17
+ turnId,
18
+ startedAt,
19
+ progress,
20
+ touch,
21
+ recordTextDelta: (characters) => {
22
+ progress.textCharacters += Math.max(0, characters);
23
+ touch();
24
+ },
25
+ recordToolStart: (toolName) => {
26
+ progress.currentTool = toolName;
27
+ progress.lastTool = toolName;
28
+ progress.toolCounts.set(toolName, (progress.toolCounts.get(toolName) ?? 0) + 1);
29
+ touch();
30
+ },
31
+ recordToolUpdate: touch,
32
+ recordToolEnd: () => {
33
+ progress.currentTool = undefined;
34
+ touch();
35
+ },
36
+ recordCompleted: () => {
37
+ progress.status = "completed";
38
+ progress.completedAt = Date.now();
39
+ progress.updatedAt = progress.completedAt;
40
+ },
41
+ recordFailed: (error) => {
42
+ progress.status = "failed";
43
+ progress.error = error;
44
+ progress.completedAt = Date.now();
45
+ progress.updatedAt = progress.completedAt;
46
+ },
47
+ };
48
+ }
49
+ export function createChannelTypingLoop(options) {
50
+ let timer;
51
+ let running = false;
52
+ const sendTyping = () => {
53
+ void options.sendTyping().catch(() => { });
54
+ };
55
+ return {
56
+ start: () => {
57
+ if (running) {
58
+ return;
59
+ }
60
+ running = true;
61
+ timer = setInterval(sendTyping, options.intervalMs);
62
+ timer.unref?.();
63
+ sendTyping();
64
+ },
65
+ stop: () => {
66
+ running = false;
67
+ if (timer) {
68
+ clearInterval(timer);
69
+ timer = undefined;
70
+ }
71
+ },
72
+ };
73
+ }