@unblocklabs/slack-subagent-card 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,814 @@
1
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
+ import type { WebClient } from "@slack/web-api";
3
+ import { createSlackWebClient } from "openclaw/plugin-sdk/slack";
4
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { join } from "path";
6
+
7
+ type Outcome = "ok" | "error" | "timeout" | "killed";
8
+ type Mode = "run" | "session";
9
+ type TimerHandle = ReturnType<typeof setTimeout>;
10
+
11
+ type Logger = {
12
+ debug?: (message: string, meta?: Record<string, unknown>) => void;
13
+ info: (message: string, meta?: Record<string, unknown>) => void;
14
+ warn: (message: string, meta?: Record<string, unknown>) => void;
15
+ error?: (message: string, meta?: Record<string, unknown>) => void;
16
+ };
17
+
18
+ type SlackRequester = {
19
+ channel?: string;
20
+ accountId?: string;
21
+ to?: string;
22
+ threadId?: string;
23
+ };
24
+
25
+ type HookContext = {
26
+ requesterSessionKey?: string;
27
+ childSessionKey?: string;
28
+ runId?: string;
29
+ sessionKey?: string;
30
+ sessionId?: string;
31
+ workspaceDir?: string;
32
+ };
33
+
34
+ type SubagentSpawnedEvent = {
35
+ runId?: string;
36
+ childSessionKey?: string;
37
+ agentId?: string;
38
+ label?: string;
39
+ requester?: SlackRequester;
40
+ threadRequested?: boolean;
41
+ mode?: Mode;
42
+ };
43
+
44
+ type SubagentEndedEvent = {
45
+ runId?: string;
46
+ endedAt?: number;
47
+ outcome?: Outcome;
48
+ error?: string;
49
+ reason?: string;
50
+ targetSessionKey?: string;
51
+ targetKind?: string;
52
+ accountId?: string;
53
+ };
54
+
55
+ type SubagentDeliveryTargetEvent = {
56
+ childRunId?: string;
57
+ childSessionKey?: string;
58
+ requesterSessionKey?: string;
59
+ requesterOrigin?: SlackRequester;
60
+ spawnMode?: Mode;
61
+ expectsCompletionMessage?: boolean;
62
+ };
63
+
64
+ type AgentEndEvent = {
65
+ success?: boolean;
66
+ error?: string;
67
+ durationMs?: number;
68
+ messages?: unknown[];
69
+ };
70
+
71
+ type PluginApi = {
72
+ logger: Logger;
73
+ config?: {
74
+ channels?: {
75
+ slack?: {
76
+ botToken?: string;
77
+ accounts?: Record<string, { botToken?: string }>;
78
+ };
79
+ };
80
+ plugins?: {
81
+ entries?: Record<string, { config?: Record<string, unknown> }>;
82
+ };
83
+ };
84
+ pluginConfig?: Record<string, unknown>;
85
+ registrationMode?: string;
86
+ runtime?: {
87
+ system?: {
88
+ requestHeartbeatNow?: (opts?: { reason?: string; sessionKey?: string }) => unknown;
89
+ enqueueSystemEvent?: (text: string, opts?: { sessionKey?: string; contextKey?: string }) => unknown;
90
+ };
91
+ state?: {
92
+ resolveStateDir?: (config?: unknown) => string;
93
+ };
94
+ };
95
+ on: (
96
+ hookName: string,
97
+ handler: (event: unknown, ctx: HookContext) => void | Promise<void>,
98
+ ) => unknown;
99
+ };
100
+
101
+ type TrackedRun = {
102
+ messageTs: string;
103
+ channelId: string;
104
+ threadTs: string;
105
+ startedAt: number;
106
+ endedAt?: number;
107
+ label: string;
108
+ requesterSessionKey: string;
109
+ nudgeTimer?: TimerHandle;
110
+ deliveryTargetHandled?: boolean;
111
+ };
112
+
113
+ type SharedState = {
114
+ runs: Map<string, TrackedRun>;
115
+ registeredApis: WeakSet<object>;
116
+ pluginBotId?: string;
117
+ };
118
+
119
+ type SlackThreadTarget = {
120
+ channelId: string;
121
+ threadTs: string;
122
+ };
123
+
124
+ const SHARED_STATE_KEY = "__slackSubagentCardSharedState";
125
+ const DEFAULT_NUDGE_DELAY_SEC = 30;
126
+ const MIN_NUDGE_DELAY_SEC = 10;
127
+ const MAX_NUDGE_DELAY_SEC = 300;
128
+ const STALE_RUN_TTL_MS = 60 * 60 * 1000;
129
+ const NUDGE_TEXT_PREFIX = "⏰ Sub-agent ";
130
+ const CARD_TEXT_PREFIX = "Sub-agent ";
131
+ const SLACK_THREAD_RE = /^agent:[^:]+:slack:(?:channel|room):([^:]+):thread:(.+)$/;
132
+ const SLACK_TOPIC_RE = /^agent:[^:]+:slack:(?:channel|room):([^-]+)-topic-(.+)$/;
133
+ const SUBAGENT_KEY_RE = /^agent:([^:]+):subagent:([0-9a-f-]+)$/;
134
+
135
+ export default definePluginEntry({
136
+ id: "slack-subagent-card",
137
+ name: "Slack Subagent Card",
138
+ description:
139
+ "Posts a Block Kit status card for thread-bound sub-agents and nudges the parent session if no follow-up appears.",
140
+
141
+ register(api) {
142
+ const pluginApi = api as unknown as PluginApi;
143
+ const log = pluginApi.logger;
144
+ const shared = getSharedState();
145
+
146
+ if (pluginApi.registrationMode !== "full") return;
147
+ if (shared.registeredApis.has(api)) return;
148
+ shared.registeredApis.add(api);
149
+
150
+ const token = resolveSlackBotToken(pluginApi);
151
+ if (!token) {
152
+ shared.registeredApis.delete(api);
153
+ log.warn("slack-subagent-card: no Slack bot token found; plugin disabled");
154
+ return;
155
+ }
156
+
157
+ const web = createSlackWebClient(token);
158
+
159
+ pluginApi.on("subagent_spawned", async (event, ctx) => {
160
+ try {
161
+ await handleSpawned(pluginApi, web, shared, event as SubagentSpawnedEvent, ctx);
162
+ } catch (error) {
163
+ log.warn(`slack-subagent-card: subagent_spawned failed: ${stringifyError(error)}`);
164
+ }
165
+ });
166
+
167
+ pluginApi.on("subagent_ended", async (event, ctx) => {
168
+ try {
169
+ await handleEnded(pluginApi, web, shared, event as SubagentEndedEvent, ctx);
170
+ } catch (error) {
171
+ log.warn(`slack-subagent-card: subagent_ended failed: ${stringifyError(error)}`);
172
+ }
173
+ });
174
+
175
+ pluginApi.on("subagent_delivery_target", async (event, ctx) => {
176
+ try {
177
+ await handleDeliveryTarget(pluginApi, web, shared, event as SubagentDeliveryTargetEvent, ctx);
178
+ } catch (error) {
179
+ log.warn(`slack-subagent-card: subagent_delivery_target failed: ${stringifyError(error)}`);
180
+ }
181
+ });
182
+
183
+ pluginApi.on("agent_end", async (event, ctx) => {
184
+ try {
185
+ await handleAgentEnd(pluginApi, event as AgentEndEvent, ctx);
186
+ } catch (error) {
187
+ log.warn(`slack-subagent-card: agent_end failed: ${stringifyError(error)}`);
188
+ }
189
+ });
190
+
191
+ log.info("slack-subagent-card plugin registered");
192
+ },
193
+ });
194
+
195
+ async function handleAgentEnd(api: PluginApi, event: AgentEndEvent, ctx: HookContext): Promise<void> {
196
+ const sessionKey = asNonEmptyString(ctx.sessionKey);
197
+ if (!sessionKey) return;
198
+
199
+ const match = sessionKey.match(SUBAGENT_KEY_RE);
200
+ if (!match) return;
201
+
202
+ const workspaceDir = asNonEmptyString(ctx.workspaceDir);
203
+ if (!workspaceDir) return;
204
+
205
+ const agentId = match[1];
206
+ const fallbackLabel = match[2];
207
+ const label = resolveSubagentLabel(api, agentId, sessionKey) ?? fallbackLabel;
208
+ const marker = {
209
+ label,
210
+ sessionKey,
211
+ sessionId: asNonEmptyString(ctx.sessionId) ?? "",
212
+ agentId,
213
+ completedAt: new Date().toISOString(),
214
+ success: Boolean(event.success),
215
+ error: event.error,
216
+ durationMs: asFiniteNumber(event.durationMs),
217
+ messageCount: Array.isArray(event.messages) ? event.messages.length : 0,
218
+ };
219
+
220
+ const resultsDir = join(workspaceDir, ".sub-agent-results");
221
+ const filename = `${sanitizeFilenameSegment(label)}-${Date.now()}.json`;
222
+
223
+ try {
224
+ mkdirSync(resultsDir, { recursive: true });
225
+ writeFileSync(join(resultsDir, filename), JSON.stringify(marker, null, 2));
226
+ api.logger.info(`slack-subagent-card: wrote completion marker ${filename}`);
227
+ } catch (err) {
228
+ api.logger.warn(`slack-subagent-card: failed to write completion marker ${filename}: ${stringifyError(err)}`);
229
+ }
230
+ }
231
+
232
+ async function handleSpawned(
233
+ api: PluginApi,
234
+ web: WebClient,
235
+ shared: SharedState,
236
+ event: SubagentSpawnedEvent,
237
+ ctx: HookContext,
238
+ ): Promise<void> {
239
+ const runId = asNonEmptyString(event.runId ?? ctx.runId);
240
+ if (!runId) return;
241
+
242
+ const requesterSessionKey = asNonEmptyString(ctx.requesterSessionKey);
243
+ if (!requesterSessionKey) return;
244
+
245
+ cleanupStaleRuns(shared, api.logger);
246
+
247
+ const target = resolveSlackThreadTarget(requesterSessionKey, event.requester);
248
+ if (!target) {
249
+ api.logger.debug?.(
250
+ `slack-subagent-card: no Slack thread target for runId=${runId} requesterSessionKey=${requesterSessionKey}`,
251
+ );
252
+ return;
253
+ }
254
+
255
+ const label = asNonEmptyString(event.label) ?? asNonEmptyString(event.agentId) ?? runId;
256
+ const existing = shared.runs.get(runId);
257
+ if (existing?.nudgeTimer) clearTimeout(existing.nudgeTimer);
258
+
259
+ const cardTitle = truncate(label, 80);
260
+
261
+ const sent = await withSlackRetry(
262
+ () =>
263
+ web.chat.postMessage({
264
+ channel: target.channelId,
265
+ thread_ts: target.threadTs,
266
+ text: `${CARD_TEXT_PREFIX}${cardTitle}: Running`,
267
+ blocks: buildBlocks({
268
+ label: cardTitle,
269
+ statusText: "⏳ Running",
270
+ taskId: runId,
271
+ }) as any,
272
+ }),
273
+ api.logger,
274
+ );
275
+
276
+ capturePluginBotId(shared, sent);
277
+
278
+ const messageTs = asNonEmptyString((sent as any)?.ts) ?? asNonEmptyString((sent as any)?.message?.ts);
279
+ if (!messageTs) {
280
+ api.logger.warn(`slack-subagent-card: postMessage returned no ts for runId=${runId}; skipping tracking`);
281
+ return;
282
+ }
283
+
284
+ shared.runs.set(runId, {
285
+ messageTs,
286
+ channelId: target.channelId,
287
+ threadTs: target.threadTs,
288
+ startedAt: Date.now(),
289
+ label: cardTitle,
290
+ requesterSessionKey,
291
+ });
292
+
293
+ api.logger.debug?.(
294
+ `slack-subagent-card: posted card for runId=${runId} channel=${target.channelId} thread=${target.threadTs}`,
295
+ );
296
+ }
297
+
298
+ async function handleDeliveryTarget(
299
+ api: PluginApi,
300
+ web: WebClient,
301
+ shared: SharedState,
302
+ event: SubagentDeliveryTargetEvent,
303
+ ctx: HookContext,
304
+ ): Promise<void> {
305
+ const runId = asNonEmptyString(event.childRunId ?? ctx.runId);
306
+ if (!runId) return;
307
+
308
+ const tracked = shared.runs.get(runId);
309
+ if (!tracked) return;
310
+
311
+ tracked.endedAt = Date.now();
312
+ tracked.deliveryTargetHandled = true;
313
+ startOrResetNudgeTimer(api, web, shared, tracked, runId, "ok");
314
+
315
+ const elapsedText = formatElapsed(Math.max(0, tracked.endedAt - tracked.startedAt));
316
+
317
+ try {
318
+ const updated = await withSlackRetry(
319
+ () =>
320
+ web.chat.update({
321
+ channel: tracked.channelId,
322
+ ts: tracked.messageTs,
323
+ text: `${CARD_TEXT_PREFIX}${tracked.label}: ✅ Completed`,
324
+ blocks: buildBlocks({
325
+ label: tracked.label,
326
+ statusText: "✅ Completed",
327
+ elapsedText,
328
+ detail: `Finished in ${elapsedText}`,
329
+ outcome: "ok",
330
+ taskId: runId,
331
+ }) as any,
332
+ }),
333
+ api.logger,
334
+ );
335
+ capturePluginBotId(shared, updated);
336
+ } catch (error) {
337
+ api.logger.warn(
338
+ `slack-subagent-card: early completion update failed for runId=${runId}: ${stringifyError(error)}`,
339
+ );
340
+ }
341
+ }
342
+
343
+ async function handleEnded(
344
+ api: PluginApi,
345
+ web: WebClient,
346
+ shared: SharedState,
347
+ event: SubagentEndedEvent,
348
+ ctx: HookContext,
349
+ ): Promise<void> {
350
+ const runId = asNonEmptyString(event.runId ?? ctx.runId);
351
+ if (!runId) return;
352
+
353
+ const tracked = shared.runs.get(runId);
354
+ if (!tracked) {
355
+ api.logger.debug?.(`slack-subagent-card: handleEnded missing tracked runId=${runId}`);
356
+ return;
357
+ }
358
+
359
+ const outcome = normalizeOutcome(event.outcome);
360
+ if (tracked.deliveryTargetHandled && outcome === "ok") return;
361
+
362
+ tracked.endedAt = asFiniteNumber(event.endedAt) ?? Date.now();
363
+ startOrResetNudgeTimer(api, web, shared, tracked, runId, outcome);
364
+
365
+ const elapsedText = formatElapsed(Math.max(0, tracked.endedAt - tracked.startedAt));
366
+ const statusText = outcomeToStatus(outcome);
367
+ const detail = outcome === "error" ? asNonEmptyString(event.error) : undefined;
368
+
369
+ try {
370
+ const updated = await withSlackRetry(
371
+ () =>
372
+ web.chat.update({
373
+ channel: tracked.channelId,
374
+ ts: tracked.messageTs,
375
+ text: `${CARD_TEXT_PREFIX}${tracked.label}: ${statusText}`,
376
+ blocks: buildBlocks({
377
+ label: tracked.label,
378
+ statusText,
379
+ elapsedText,
380
+ detail,
381
+ outcome,
382
+ taskId: runId,
383
+ }) as any,
384
+ }),
385
+ api.logger,
386
+ );
387
+ capturePluginBotId(shared, updated);
388
+ } catch (error) {
389
+ api.logger.warn(
390
+ `slack-subagent-card: terminal card update failed for runId=${runId}: ${stringifyError(error)}`,
391
+ );
392
+ }
393
+ }
394
+
395
+ function startOrResetNudgeTimer(
396
+ api: PluginApi,
397
+ web: WebClient,
398
+ shared: SharedState,
399
+ tracked: TrackedRun,
400
+ runId: string,
401
+ outcome: Outcome,
402
+ ): void {
403
+ if (tracked.nudgeTimer) clearTimeout(tracked.nudgeTimer);
404
+
405
+ const delayMs = resolveNudgeDelayMs(api);
406
+ tracked.nudgeTimer = setTimeout(() => {
407
+ void maybeSendNudge(api, web, shared, runId, outcome).catch((error) => {
408
+ api.logger.warn(`slack-subagent-card: maybeSendNudge failed for runId=${runId}: ${stringifyError(error)}`);
409
+ });
410
+ }, delayMs);
411
+ tracked.nudgeTimer.unref?.();
412
+ }
413
+
414
+ async function maybeSendNudge(
415
+ api: PluginApi,
416
+ web: WebClient,
417
+ shared: SharedState,
418
+ runId: string,
419
+ outcome: Outcome,
420
+ ): Promise<void> {
421
+ const tracked = shared.runs.get(runId);
422
+ if (!tracked) return;
423
+
424
+ try {
425
+ const endedAt = tracked.endedAt ?? Date.now();
426
+ const replies = await withSlackRetry(
427
+ () =>
428
+ web.conversations.replies({
429
+ channel: tracked.channelId,
430
+ ts: tracked.threadTs,
431
+ inclusive: true,
432
+ oldest: String(endedAt / 1000),
433
+ limit: 200,
434
+ }),
435
+ api.logger,
436
+ );
437
+
438
+ const messages = Array.isArray((replies as any)?.messages) ? ((replies as any).messages as unknown[]) : [];
439
+ const hasFollowUp = messages.some((message) => {
440
+ const tsMs = slackTsToMs((message as SlackMessage)?.ts);
441
+ if (tsMs === undefined || tsMs <= endedAt) return false;
442
+ return !isOwnPluginMessage(message as SlackMessage, tracked, shared);
443
+ });
444
+
445
+ if (hasFollowUp) return;
446
+
447
+ const ageText = formatElapsed(Math.max(0, Date.now() - endedAt));
448
+ const nudgeText = `${NUDGE_TEXT_PREFIX}*${escapeMrkdwn(tracked.label)}* finished with outcome *${outcome}* ${ageText} ago, but no response has been posted yet.`;
449
+
450
+ const posted = await withSlackRetry(
451
+ () =>
452
+ web.chat.postMessage({
453
+ channel: tracked.channelId,
454
+ thread_ts: tracked.threadTs,
455
+ text: nudgeText,
456
+ }),
457
+ api.logger,
458
+ );
459
+
460
+ capturePluginBotId(shared, posted);
461
+ wakeParentSession(api, tracked, runId, outcome);
462
+ api.logger.info(`slack-subagent-card: nudge posted for runId=${runId}`);
463
+ } finally {
464
+ cleanupTrackedRun(shared, runId);
465
+ }
466
+ }
467
+
468
+ type SlackMessage = {
469
+ ts?: string;
470
+ text?: string;
471
+ bot_id?: string;
472
+ app_id?: string;
473
+ subtype?: string;
474
+ is_bot?: boolean;
475
+ blocks?: Array<{ type?: string }>;
476
+ };
477
+
478
+ function isOwnPluginMessage(message: SlackMessage, tracked: TrackedRun, _shared: SharedState): boolean {
479
+ if (asNonEmptyString(message.ts) === tracked.messageTs) return true;
480
+
481
+ const text = asNonEmptyString(message.text) ?? "";
482
+ if (text.startsWith(NUDGE_TEXT_PREFIX)) return true;
483
+
484
+ const hasPlanBlock = Array.isArray(message.blocks) && message.blocks.some((block) => block?.type === "plan");
485
+ if (hasPlanBlock && text.startsWith(`${CARD_TEXT_PREFIX}${tracked.label}:`)) return true;
486
+
487
+ return false;
488
+ }
489
+
490
+ function wakeParentSession(api: PluginApi, tracked: TrackedRun, runId: string, outcome: Outcome): void {
491
+ const sessionKey = tracked.requesterSessionKey;
492
+ const contextKey = `slack-subagent-card:${runId}:pending-result`;
493
+ const eventText =
494
+ `WAKE UP — sub-agent "${tracked.label}" finished (outcome: ${outcome}). ` +
495
+ `This is NOT a heartbeat. Do NOT reply HEARTBEAT_OK. ` +
496
+ `You have a pending sub-agent result that needs to be delivered to the user. ` +
497
+ `Your normal reply will NOT reach Slack because this wake has no inbound delivery context. ` +
498
+ `You MUST use the message tool to post your response: ` +
499
+ `message(action="send", channel="slack", target="${tracked.channelId}", ` +
500
+ `threadId="${tracked.threadTs}", message="<your response>"). ` +
501
+ `Check sessions_history or .sub-agent-results for the sub-agent output first.`;
502
+
503
+ try {
504
+ api.runtime?.system?.enqueueSystemEvent?.(eventText, { sessionKey, contextKey });
505
+ } catch (error) {
506
+ api.logger.warn(`slack-subagent-card: enqueueSystemEvent failed: ${stringifyError(error)}`);
507
+ }
508
+
509
+ try {
510
+ api.runtime?.system?.requestHeartbeatNow?.({ sessionKey, reason: contextKey });
511
+ } catch (error) {
512
+ api.logger.debug?.(`slack-subagent-card: requestHeartbeatNow failed: ${stringifyError(error)}`);
513
+ }
514
+ }
515
+
516
+ function buildBlocks(params: {
517
+ label: string;
518
+ statusText: string;
519
+ elapsedText?: string;
520
+ detail?: string;
521
+ outcome?: Outcome;
522
+ taskId?: string;
523
+ }): Array<Record<string, unknown>> {
524
+ let taskStatus = "in_progress";
525
+ if (params.outcome === "ok") taskStatus = "complete";
526
+ else if (params.outcome === "error" || params.outcome === "timeout" || params.outcome === "killed") {
527
+ taskStatus = "failed";
528
+ }
529
+
530
+ const titleParts = [params.label];
531
+ if (params.elapsedText) titleParts.push(`(${params.elapsedText})`);
532
+
533
+ const task: Record<string, unknown> = {
534
+ task_id: params.taskId ?? "subagent_1",
535
+ title: titleParts.join(" "),
536
+ status: taskStatus,
537
+ };
538
+
539
+ if (params.detail) {
540
+ task.output = {
541
+ type: "rich_text",
542
+ elements: [
543
+ {
544
+ type: "rich_text_section",
545
+ elements: [{ type: "text", text: truncate(params.detail, 300) }],
546
+ },
547
+ ],
548
+ };
549
+ }
550
+
551
+ return [
552
+ {
553
+ type: "plan",
554
+ title: params.statusText,
555
+ tasks: [task],
556
+ },
557
+ ];
558
+ }
559
+
560
+ function outcomeToStatus(outcome: Outcome): string {
561
+ switch (outcome) {
562
+ case "ok":
563
+ return "✅ Completed";
564
+ case "error":
565
+ return "❌ Error";
566
+ case "timeout":
567
+ return "⏱️ Timed out";
568
+ case "killed":
569
+ return "🔪 Killed";
570
+ }
571
+ }
572
+
573
+ function normalizeOutcome(value: unknown): Outcome {
574
+ return value === "error" || value === "timeout" || value === "killed" ? value : "ok";
575
+ }
576
+
577
+ function resolveSlackThreadTarget(
578
+ requesterSessionKey: string,
579
+ requester?: SlackRequester,
580
+ ): SlackThreadTarget | null {
581
+ const fromSession = parseSlackThreadSessionKey(requesterSessionKey);
582
+ if (fromSession) return fromSession;
583
+
584
+ const rawTarget = asNonEmptyString(requester?.to);
585
+ const threadTs = asNonEmptyString(requester?.threadId);
586
+ if (!rawTarget || !threadTs) return null;
587
+
588
+ return {
589
+ channelId: normalizeSlackChannelId(stripSlackTargetPrefix(rawTarget)),
590
+ threadTs,
591
+ };
592
+ }
593
+
594
+ function parseSlackThreadSessionKey(sessionKey: string): SlackThreadTarget | null {
595
+ const threadMatch = sessionKey.match(SLACK_THREAD_RE);
596
+ if (threadMatch) {
597
+ return {
598
+ channelId: normalizeSlackChannelId(threadMatch[1]),
599
+ threadTs: threadMatch[2],
600
+ };
601
+ }
602
+
603
+ const topicMatch = sessionKey.match(SLACK_TOPIC_RE);
604
+ if (topicMatch) {
605
+ return {
606
+ channelId: normalizeSlackChannelId(topicMatch[1]),
607
+ threadTs: topicMatch[2],
608
+ };
609
+ }
610
+
611
+ return null;
612
+ }
613
+
614
+ function stripSlackTargetPrefix(value: string): string {
615
+ return value.replace(/^(channel:|room:)/i, "");
616
+ }
617
+
618
+ function resolveSlackBotToken(api: PluginApi): string | undefined {
619
+ return (
620
+ asNonEmptyString(api.config?.channels?.slack?.botToken) ??
621
+ asNonEmptyString(api.config?.channels?.slack?.accounts?.default?.botToken) ??
622
+ asNonEmptyString(process.env.SLACK_BOT_TOKEN)
623
+ );
624
+ }
625
+
626
+ function resolveSubagentLabel(api: PluginApi, agentId: string, sessionKey: string): string | undefined {
627
+ try {
628
+ const stateDir = api.runtime?.state?.resolveStateDir?.(api.config);
629
+ if (!stateDir) return undefined;
630
+
631
+ const sessionsPath = join(stateDir, "agents", agentId, "sessions", "sessions.json");
632
+ const sessions = JSON.parse(readFileSync(sessionsPath, "utf-8")) as Record<string, { label?: unknown }>;
633
+ return asNonEmptyString(sessions?.[sessionKey]?.label);
634
+ } catch {
635
+ return undefined;
636
+ }
637
+ }
638
+
639
+ function resolveNudgeDelayMs(api: PluginApi): number {
640
+ // Check plugin config (api.pluginConfig.nudgeDelaySec)
641
+ const fromPluginConfig = asFiniteNumber((api.pluginConfig as any)?.nudgeDelaySec);
642
+ if (fromPluginConfig !== undefined) {
643
+ const clamped = Math.max(MIN_NUDGE_DELAY_SEC, Math.min(MAX_NUDGE_DELAY_SEC, fromPluginConfig));
644
+ return clamped * 1000;
645
+ }
646
+
647
+ // Legacy: check plugins.entries.slack-subagent-card.config.nudgeDelaySec
648
+ const fromEntries = asFiniteNumber(
649
+ api.config?.plugins?.entries?.["slack-subagent-card"]?.config?.nudgeDelaySec
650
+ );
651
+ if (fromEntries !== undefined) {
652
+ const clamped = Math.max(MIN_NUDGE_DELAY_SEC, Math.min(MAX_NUDGE_DELAY_SEC, fromEntries));
653
+ return clamped * 1000;
654
+ }
655
+
656
+ // Env var fallback
657
+ const fromEnv = asFiniteNumber(process.env.SUBAGENT_NUDGE_DELAY_SEC);
658
+ if (fromEnv !== undefined) {
659
+ const clamped = Math.max(MIN_NUDGE_DELAY_SEC, Math.min(MAX_NUDGE_DELAY_SEC, fromEnv));
660
+ return clamped * 1000;
661
+ }
662
+
663
+ return DEFAULT_NUDGE_DELAY_SEC * 1000;
664
+ }
665
+
666
+ function sanitizeFilenameSegment(value: string): string {
667
+ const cleaned = value
668
+ .trim()
669
+ .replace(/[^a-z0-9._-]+/gi, "-")
670
+ .replace(/-+/g, "-")
671
+ .replace(/^-|-$/g, "");
672
+
673
+ return cleaned || "subagent";
674
+ }
675
+
676
+ async function withSlackRetry<T>(
677
+ fn: () => Promise<T>,
678
+ log: Pick<Logger, "warn">,
679
+ ): Promise<T> {
680
+ try {
681
+ return await fn();
682
+ } catch (error) {
683
+ const retryAfterSec = getRetryAfterSeconds(error);
684
+ if (!retryAfterSec) throw error;
685
+
686
+ log.warn(`slack-subagent-card: hit Slack rate limit, retrying in ${retryAfterSec}s`);
687
+ await sleep(retryAfterSec * 1000);
688
+ return fn();
689
+ }
690
+ }
691
+
692
+ function getRetryAfterSeconds(error: unknown): number | undefined {
693
+ const record = asRecord(error);
694
+ const data = asRecord(record?.data);
695
+ const headers = asRecord(data?.headers);
696
+ const retryAfter =
697
+ asFiniteNumber(data?.retryAfter) ??
698
+ asFiniteNumber(data?.retry_after) ??
699
+ asFiniteNumber(headers?.["retry-after"]) ??
700
+ asFiniteNumber(record?.retryAfter);
701
+
702
+ return retryAfter && retryAfter > 0 ? retryAfter : undefined;
703
+ }
704
+
705
+ function slackTsToMs(value: unknown): number | undefined {
706
+ if (typeof value === "number" && Number.isFinite(value)) return value * 1000;
707
+ if (typeof value !== "string" || !value.trim()) return undefined;
708
+ const parsed = Number(value);
709
+ return Number.isFinite(parsed) ? parsed * 1000 : undefined;
710
+ }
711
+
712
+ function formatElapsed(ms: number): string {
713
+ if (ms < 1000) return "just now";
714
+ const totalSeconds = Math.floor(ms / 1000);
715
+ if (totalSeconds < 60) return `${totalSeconds}s`;
716
+ const minutes = Math.floor(totalSeconds / 60);
717
+ const seconds = totalSeconds % 60;
718
+ if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
719
+ const hours = Math.floor(minutes / 60);
720
+ const remMinutes = minutes % 60;
721
+ return remMinutes > 0 ? `${hours}h ${remMinutes}m` : `${hours}h`;
722
+ }
723
+
724
+ function truncate(value: string, max: number): string {
725
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
726
+ }
727
+
728
+ function escapeMrkdwn(value: string): string {
729
+ return value.replace(/[&<>]/g, (ch) => {
730
+ if (ch === "&") return "&amp;";
731
+ if (ch === "<") return "&lt;";
732
+ return "&gt;";
733
+ });
734
+ }
735
+
736
+ function normalizeSlackChannelId(channelId: string): string {
737
+ return /^[cgd]/i.test(channelId) ? channelId.toUpperCase() : channelId;
738
+ }
739
+
740
+ function cleanupStaleRuns(shared: SharedState, log: Logger): void {
741
+ const cutoff = Date.now() - STALE_RUN_TTL_MS;
742
+ for (const [runId, tracked] of shared.runs.entries()) {
743
+ if (tracked.startedAt >= cutoff) continue;
744
+ if (tracked.nudgeTimer) clearTimeout(tracked.nudgeTimer);
745
+ shared.runs.delete(runId);
746
+ log.debug?.(`slack-subagent-card: swept stale tracked runId=${runId}`);
747
+ }
748
+ }
749
+
750
+ function cleanupTrackedRun(shared: SharedState, runId: string): void {
751
+ const tracked = shared.runs.get(runId);
752
+ if (tracked?.nudgeTimer) clearTimeout(tracked.nudgeTimer);
753
+ shared.runs.delete(runId);
754
+ }
755
+
756
+ function capturePluginBotId(shared: SharedState, response: unknown): void {
757
+ const record = asRecord(response);
758
+ const message = asRecord(record?.message);
759
+ const botId =
760
+ asNonEmptyString(record?.bot_id) ??
761
+ asNonEmptyString(message?.bot_id) ??
762
+ asNonEmptyString(message?.botId) ??
763
+ asNonEmptyString(record?.botId);
764
+
765
+ if (botId) shared.pluginBotId = botId;
766
+ }
767
+
768
+ function getSharedState(): SharedState {
769
+ const scope = globalThis as typeof globalThis & {
770
+ [SHARED_STATE_KEY]?: SharedState;
771
+ };
772
+
773
+ if (!scope[SHARED_STATE_KEY]) {
774
+ scope[SHARED_STATE_KEY] = {
775
+ runs: new Map(),
776
+ registeredApis: new WeakSet(),
777
+ };
778
+ }
779
+
780
+ return scope[SHARED_STATE_KEY]!;
781
+ }
782
+
783
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
784
+ return value && typeof value === "object" && !Array.isArray(value)
785
+ ? (value as Record<string, unknown>)
786
+ : undefined;
787
+ }
788
+
789
+ function asFiniteNumber(value: unknown): number | undefined {
790
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
791
+ if (typeof value === "string" && value.trim()) {
792
+ const parsed = Number(value);
793
+ return Number.isFinite(parsed) ? parsed : undefined;
794
+ }
795
+ return undefined;
796
+ }
797
+
798
+ function asNonEmptyString(value: unknown): string | undefined {
799
+ return typeof value === "string" && value.trim() ? value : undefined;
800
+ }
801
+
802
+ function sleep(ms: number): Promise<void> {
803
+ return new Promise((resolve) => setTimeout(resolve, ms));
804
+ }
805
+
806
+ function stringifyError(error: unknown): string {
807
+ if (error instanceof Error) return error.stack || error.message;
808
+ if (typeof error === "string") return error;
809
+ try {
810
+ return JSON.stringify(error);
811
+ } catch {
812
+ return String(error);
813
+ }
814
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": "slack-subagent-card",
3
+ "name": "Slack Subagent Card",
4
+ "version": "1.0.0",
5
+ "description": "Posts and updates a Slack Block Kit status card for thread-bound sub-agents, nudges quiet parent threads after completion, wakes parent sessions, and writes completion markers.",
6
+ "entry": "index.ts",
7
+ "configSchema": {
8
+ "type": "object",
9
+ "properties": {
10
+ "nudgeDelaySec": {
11
+ "type": "number",
12
+ "minimum": 10,
13
+ "maximum": 300,
14
+ "default": 30,
15
+ "description": "Seconds to wait after sub-agent completion before sending a nudge (default: 30)"
16
+ }
17
+ },
18
+ "additionalProperties": false
19
+ }
20
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@unblocklabs/slack-subagent-card",
3
+ "version": "1.0.0",
4
+ "description": "OpenClaw plugin: Slack Block Kit status cards for sub-agents, nudge timer, parent session wake, and completion markers.",
5
+ "type": "module",
6
+ "openclaw": {
7
+ "extensions": [
8
+ "./index.ts"
9
+ ]
10
+ },
11
+ "keywords": [
12
+ "openclaw",
13
+ "plugin",
14
+ "slack",
15
+ "subagent",
16
+ "block-kit",
17
+ "notifications"
18
+ ],
19
+ "license": "MIT",
20
+ "author": "Unblock Labs",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/unblocklabs-ai/openclaw-slack-subagent-card"
24
+ },
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "peerDependencies": {
29
+ "openclaw": ">=2026.3.28"
30
+ },
31
+ "files": [
32
+ "index.ts",
33
+ "openclaw.plugin.json"
34
+ ]
35
+ }