@calltelemetry/openclaw-linear 0.6.1 → 0.7.1

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -17
  3. package/index.ts +57 -22
  4. package/openclaw.plugin.json +37 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +47 -0
  7. package/src/api/linear-api.test.ts +494 -0
  8. package/src/api/linear-api.ts +193 -19
  9. package/src/gateway/dispatch-methods.ts +243 -0
  10. package/src/infra/cli.ts +284 -29
  11. package/src/infra/codex-worktree.ts +83 -0
  12. package/src/infra/commands.ts +156 -0
  13. package/src/infra/doctor.test.ts +4 -4
  14. package/src/infra/doctor.ts +7 -29
  15. package/src/infra/file-lock.test.ts +61 -0
  16. package/src/infra/file-lock.ts +49 -0
  17. package/src/infra/multi-repo.ts +85 -0
  18. package/src/infra/notify.test.ts +357 -108
  19. package/src/infra/notify.ts +222 -43
  20. package/src/infra/observability.ts +48 -0
  21. package/src/infra/resilience.test.ts +94 -0
  22. package/src/infra/resilience.ts +101 -0
  23. package/src/pipeline/artifacts.ts +38 -2
  24. package/src/pipeline/dag-dispatch.test.ts +553 -0
  25. package/src/pipeline/dag-dispatch.ts +390 -0
  26. package/src/pipeline/dispatch-service.ts +48 -1
  27. package/src/pipeline/dispatch-state.ts +2 -42
  28. package/src/pipeline/pipeline.ts +91 -17
  29. package/src/pipeline/planner.test.ts +334 -0
  30. package/src/pipeline/planner.ts +287 -0
  31. package/src/pipeline/planning-state.test.ts +236 -0
  32. package/src/pipeline/planning-state.ts +178 -0
  33. package/src/pipeline/tier-assess.test.ts +175 -0
  34. package/src/pipeline/webhook.ts +90 -17
  35. package/src/tools/dispatch-history-tool.ts +201 -0
  36. package/src/tools/orchestration-tools.test.ts +158 -0
  37. package/src/tools/planner-tools.test.ts +535 -0
  38. package/src/tools/planner-tools.ts +450 -0
package/src/infra/cli.ts CHANGED
@@ -5,14 +5,20 @@ import type { Command } from "commander";
5
5
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
6
6
  import { createInterface } from "node:readline";
7
7
  import { exec } from "node:child_process";
8
- import { readFileSync, writeFileSync } from "node:fs";
9
- import { readFileSync as readFileSyncFs, existsSync } from "node:fs";
8
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
10
9
  import { join, dirname } from "node:path";
11
10
  import { fileURLToPath } from "node:url";
12
11
  import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
13
12
  import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "../api/auth.js";
14
13
  import { listWorktrees } from "./codex-worktree.js";
15
14
  import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
15
+ import {
16
+ formatMessage,
17
+ parseNotificationsConfig,
18
+ sendToTarget,
19
+ type NotifyKind,
20
+ type NotifyPayload,
21
+ } from "./notify.js";
16
22
 
17
23
  function prompt(question: string): Promise<string> {
18
24
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -251,33 +257,21 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
251
257
 
252
258
  prompts
253
259
  .command("show")
254
- .description("Print current prompts.yaml content")
255
- .action(async () => {
260
+ .description("Print resolved prompts (global or per-project)")
261
+ .option("--worktree <path>", "Show merged prompts for a specific worktree")
262
+ .action(async (opts: { worktree?: string }) => {
256
263
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
257
- const customPath = pluginConfig?.promptsPath as string | undefined;
264
+ clearPromptCache();
265
+ const loaded = loadPrompts(pluginConfig, opts.worktree);
258
266
 
259
- let resolvedPath: string;
260
- if (customPath) {
261
- resolvedPath = customPath.startsWith("~")
262
- ? customPath.replace("~", process.env.HOME ?? "")
263
- : customPath;
267
+ if (opts.worktree) {
268
+ console.log(`\nResolved prompts for worktree: ${opts.worktree}\n`);
264
269
  } else {
265
- const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
266
- resolvedPath = join(pluginRoot, "prompts.yaml");
270
+ console.log(`\nGlobal resolved prompts\n`);
267
271
  }
268
272
 
269
- console.log(`\nPrompts file: ${resolvedPath}\n`);
270
-
271
- try {
272
- const content = readFileSyncFs(resolvedPath, "utf-8");
273
- console.log(content);
274
- } catch {
275
- console.log("(file not found — using built-in defaults)\n");
276
- // Show the loaded defaults
277
- clearPromptCache();
278
- const loaded = loadPrompts(pluginConfig);
279
- console.log(JSON.stringify(loaded, null, 2));
280
- }
273
+ console.log(JSON.stringify(loaded, null, 2));
274
+ console.log();
281
275
  });
282
276
 
283
277
  prompts
@@ -303,13 +297,14 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
303
297
 
304
298
  prompts
305
299
  .command("validate")
306
- .description("Validate prompts.yaml structure")
307
- .action(async () => {
300
+ .description("Validate prompts.yaml structure (global or per-project)")
301
+ .option("--worktree <path>", "Validate merged prompts for a specific worktree")
302
+ .action(async (opts: { worktree?: string }) => {
308
303
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
309
304
  clearPromptCache();
310
305
 
311
306
  try {
312
- const loaded = loadPrompts(pluginConfig);
307
+ const loaded = loadPrompts(pluginConfig, opts.worktree);
313
308
  const errors: string[] = [];
314
309
 
315
310
  if (!loaded.worker?.system) errors.push("Missing worker.system");
@@ -329,13 +324,14 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
329
324
  }
330
325
  }
331
326
 
327
+ const label = opts.worktree ? `worktree ${opts.worktree}` : "global";
332
328
  if (errors.length > 0) {
333
- console.log("\nValidation FAILED:\n");
329
+ console.log(`\nValidation FAILED (${label}):\n`);
334
330
  for (const e of errors) console.log(` - ${e}`);
335
331
  console.log();
336
332
  process.exitCode = 1;
337
333
  } else {
338
- console.log("\nValidation PASSED — all sections and template variables present.\n");
334
+ console.log(`\nValidation PASSED (${label}) — all sections and template variables present.\n`);
339
335
  }
340
336
  } catch (err) {
341
337
  console.error(`\nFailed to load prompts: ${err}\n`);
@@ -343,6 +339,265 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
343
339
  }
344
340
  });
345
341
 
342
+ prompts
343
+ .command("init")
344
+ .description("Scaffold per-project .claw/prompts.yaml in a worktree")
345
+ .argument("<worktree-path>", "Path to the worktree")
346
+ .action(async (worktreePath: string) => {
347
+ const { mkdirSync, writeFileSync: writeFS } = await import("node:fs");
348
+ const clawDir = join(worktreePath, ".claw");
349
+ const promptsFile = join(clawDir, "prompts.yaml");
350
+
351
+ if (existsSync(promptsFile)) {
352
+ console.log(`\n ${promptsFile} already exists.\n`);
353
+ return;
354
+ }
355
+
356
+ mkdirSync(clawDir, { recursive: true });
357
+ writeFS(promptsFile, [
358
+ "# Per-project prompt overrides for Linear Agent pipeline.",
359
+ "# Only include sections/fields you want to override.",
360
+ "# Unspecified fields inherit from the global prompts.yaml.",
361
+ "#",
362
+ "# Available sections: worker, audit, rework",
363
+ "# Template variables: {{identifier}}, {{title}}, {{description}}, {{worktreePath}}, {{tier}}, {{attempt}}, {{gaps}}",
364
+ "",
365
+ "# worker:",
366
+ "# system: \"Custom system prompt for workers in this project.\"",
367
+ "# task: \"Implement issue {{identifier}}: {{title}}\\n\\n{{description}}\\n\\nWorktree: {{worktreePath}}\"",
368
+ "",
369
+ "# audit:",
370
+ "# system: \"Custom audit system prompt for this project.\"",
371
+ "",
372
+ "# rework:",
373
+ "# addendum: \"Custom rework addendum for this project.\"",
374
+ "",
375
+ ].join("\n"), "utf-8");
376
+
377
+ console.log(`\n Created: ${promptsFile}`);
378
+ console.log(` Edit this file to customize prompts for this worktree.\n`);
379
+ });
380
+
381
+ prompts
382
+ .command("diff")
383
+ .description("Show differences between global and per-project prompts")
384
+ .argument("<worktree-path>", "Path to the worktree")
385
+ .action(async (worktreePath: string) => {
386
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
387
+ clearPromptCache();
388
+
389
+ const global = loadPrompts(pluginConfig);
390
+ const merged = loadPrompts(pluginConfig, worktreePath);
391
+
392
+ const projectFile = join(worktreePath, ".claw", "prompts.yaml");
393
+ if (!existsSync(projectFile)) {
394
+ console.log(`\n No per-project prompts at ${projectFile}`);
395
+ console.log(` Run 'openclaw openclaw-linear prompts init ${worktreePath}' to create one.\n`);
396
+ return;
397
+ }
398
+
399
+ console.log(`\nPrompt diff: global vs ${worktreePath}\n`);
400
+
401
+ let hasDiffs = false;
402
+ for (const section of ["worker", "audit", "rework"] as const) {
403
+ const globalSection = global[section] as Record<string, string>;
404
+ const mergedSection = merged[section] as Record<string, string>;
405
+ for (const [key, val] of Object.entries(mergedSection)) {
406
+ if (globalSection[key] !== val) {
407
+ hasDiffs = true;
408
+ console.log(` ${section}.${key}:`);
409
+ console.log(` global: ${globalSection[key]?.slice(0, 100)}...`);
410
+ console.log(` project: ${val.slice(0, 100)}...`);
411
+ console.log();
412
+ }
413
+ }
414
+ }
415
+
416
+ if (!hasDiffs) {
417
+ console.log(" No differences — per-project prompts match global.\n");
418
+ }
419
+ });
420
+
421
+ // --- openclaw openclaw-linear notify ---
422
+ const notifyCmd = linear
423
+ .command("notify")
424
+ .description("Manage dispatch lifecycle notifications");
425
+
426
+ notifyCmd
427
+ .command("status")
428
+ .description("Show current notification target configuration")
429
+ .action(async () => {
430
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
431
+ const config = parseNotificationsConfig(pluginConfig);
432
+
433
+ console.log("\nNotification Targets");
434
+ console.log("─".repeat(50));
435
+
436
+ if (!config.targets?.length) {
437
+ console.log("\n No notification targets configured.");
438
+ console.log(" Run 'openclaw openclaw-linear notify setup' to configure.\n");
439
+ return;
440
+ }
441
+
442
+ for (const t of config.targets) {
443
+ const acct = t.accountId ? ` (account: ${t.accountId})` : "";
444
+ console.log(` ${t.channel}: ${t.target}${acct}`);
445
+ }
446
+
447
+ // Show event toggles if any are suppressed
448
+ const suppressed = Object.entries(config.events ?? {})
449
+ .filter(([, v]) => v === false)
450
+ .map(([k]) => k);
451
+ if (suppressed.length > 0) {
452
+ console.log(`\n Suppressed events: ${suppressed.join(", ")}`);
453
+ }
454
+
455
+ console.log();
456
+ });
457
+
458
+ notifyCmd
459
+ .command("test")
460
+ .description("Send a test notification to all configured targets")
461
+ .option("--channel <name>", "Test only targets for a specific channel (discord, slack, telegram, etc.)")
462
+ .action(async (opts: { channel?: string }) => {
463
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
464
+ const config = parseNotificationsConfig(pluginConfig);
465
+
466
+ const testPayload: NotifyPayload = {
467
+ identifier: "TEST-0",
468
+ title: "Test notification from Linear plugin",
469
+ status: "test",
470
+ };
471
+ const testKind: NotifyKind = "dispatch";
472
+ const message = formatMessage(testKind, testPayload);
473
+
474
+ console.log("\nSending test notification...\n");
475
+
476
+ if (!config.targets?.length) {
477
+ console.error(" No notification targets configured. Run 'openclaw openclaw-linear notify setup' first.\n");
478
+ process.exitCode = 1;
479
+ return;
480
+ }
481
+
482
+ const targets = opts.channel
483
+ ? config.targets.filter((t) => t.channel === opts.channel)
484
+ : config.targets;
485
+
486
+ if (targets.length === 0) {
487
+ console.error(` No targets found for channel "${opts.channel}".\n`);
488
+ process.exitCode = 1;
489
+ return;
490
+ }
491
+
492
+ for (const target of targets) {
493
+ try {
494
+ await sendToTarget(target, message, api.runtime);
495
+ console.log(` ${target.channel}: SENT to ${target.target}`);
496
+ console.log(` "${message}"`);
497
+ } catch (err) {
498
+ console.error(` ${target.channel}: FAILED — ${err instanceof Error ? err.message : String(err)}`);
499
+ }
500
+ }
501
+
502
+ console.log();
503
+ });
504
+
505
+ notifyCmd
506
+ .command("setup")
507
+ .description("Interactive setup for notification targets")
508
+ .action(async () => {
509
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
510
+ const config = parseNotificationsConfig(pluginConfig);
511
+
512
+ console.log("\nNotification Target Setup");
513
+ console.log("─".repeat(50));
514
+ console.log(" Dispatch lifecycle notifications can be sent to any OpenClaw channel.");
515
+ console.log(" Add multiple targets for fan-out delivery.\n");
516
+
517
+ // Show current targets
518
+ if (config.targets?.length) {
519
+ console.log(" Current targets:");
520
+ for (const t of config.targets) {
521
+ const acct = t.accountId ? ` (account: ${t.accountId})` : "";
522
+ console.log(` ${t.channel}: ${t.target}${acct}`);
523
+ }
524
+ console.log();
525
+ }
526
+
527
+ const newTargets = [...(config.targets ?? [])];
528
+ const supportedChannels = ["discord", "slack", "telegram", "signal"];
529
+
530
+ // Add targets loop
531
+ let addMore = true;
532
+ while (addMore) {
533
+ const channelAnswer = await prompt(
534
+ `Add notification target? (${supportedChannels.join("/")}) or blank to finish: `,
535
+ );
536
+ if (!channelAnswer) {
537
+ addMore = false;
538
+ break;
539
+ }
540
+
541
+ const channel = channelAnswer.toLowerCase().trim();
542
+ const targetId = await prompt(` ${channel} target ID (channel/group/user): `);
543
+ if (!targetId) continue;
544
+
545
+ let accountId: string | undefined;
546
+ if (channel === "slack") {
547
+ const acct = await prompt(" Slack account ID (leave blank for default): ");
548
+ accountId = acct || undefined;
549
+ }
550
+
551
+ newTargets.push({ channel, target: targetId, ...(accountId ? { accountId } : {}) });
552
+ console.log(` Added: ${channel} → ${targetId}\n`);
553
+ }
554
+
555
+ // Summary
556
+ console.log("\nConfiguration Summary");
557
+ console.log("─".repeat(50));
558
+ if (newTargets.length === 0) {
559
+ console.log(" No targets configured (notifications disabled).");
560
+ } else {
561
+ for (const t of newTargets) {
562
+ const acct = t.accountId ? ` (account: ${t.accountId})` : "";
563
+ console.log(` ${t.channel}: ${t.target}${acct}`);
564
+ }
565
+ }
566
+
567
+ if (JSON.stringify(newTargets) === JSON.stringify(config.targets ?? [])) {
568
+ console.log("\n No changes made.\n");
569
+ return;
570
+ }
571
+
572
+ // Write config
573
+ const confirmAnswer = await prompt("\nApply these changes? [Y/n]: ");
574
+ if (confirmAnswer.toLowerCase() === "n") {
575
+ console.log(" Aborted.\n");
576
+ return;
577
+ }
578
+
579
+ try {
580
+ const runtimeConfig = api.runtime.config.loadConfig() as Record<string, any>;
581
+ const pluginEntries = runtimeConfig.plugins?.entries ?? {};
582
+ const linearConfig = pluginEntries["openclaw-linear"]?.config ?? {};
583
+ linearConfig.notifications = {
584
+ ...linearConfig.notifications,
585
+ targets: newTargets,
586
+ };
587
+ pluginEntries["openclaw-linear"] = {
588
+ ...pluginEntries["openclaw-linear"],
589
+ config: linearConfig,
590
+ };
591
+ runtimeConfig.plugins = { ...runtimeConfig.plugins, entries: pluginEntries };
592
+ api.runtime.config.writeConfigFile(runtimeConfig);
593
+ console.log("\n Configuration saved. Restart gateway to apply: systemctl --user restart openclaw-gateway\n");
594
+ } catch (err) {
595
+ console.error(`\n Failed to save config: ${err instanceof Error ? err.message : String(err)}`);
596
+ console.error(" You can manually add these values to openclaw.json → plugins.entries.openclaw-linear.config\n");
597
+ process.exitCode = 1;
598
+ }
599
+ });
600
+
346
601
  // --- openclaw openclaw-linear doctor ---
347
602
  linear
348
603
  .command("doctor")
@@ -3,6 +3,7 @@ import { existsSync, statSync, readdirSync, mkdirSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { ensureGitignore } from "../pipeline/artifacts.js";
6
+ import type { RepoConfig } from "./multi-repo.js";
6
7
 
7
8
  const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
8
9
  const DEFAULT_WORKTREE_BASE_DIR = path.join(homedir(), ".openclaw", "worktrees");
@@ -117,6 +118,88 @@ export function createWorktree(
117
118
  return { path: worktreePath, branch, resumed: false };
118
119
  }
119
120
 
121
+ export interface MultiWorktreeResult {
122
+ /** Parent directory containing all repo worktrees for this issue. */
123
+ parentPath: string;
124
+ worktrees: Array<{
125
+ repoName: string;
126
+ path: string;
127
+ branch: string;
128
+ resumed: boolean;
129
+ }>;
130
+ }
131
+
132
+ /**
133
+ * Create worktrees for multiple repos.
134
+ *
135
+ * Layout: {baseDir}/{issueIdentifier}/{repoName}/
136
+ * Branch: codex/{issueIdentifier} (same branch name in each repo)
137
+ *
138
+ * Each individual repo worktree follows the same idempotent/resume logic
139
+ * as createWorktree: if the worktree or branch already exists, it resumes.
140
+ */
141
+ export function createMultiWorktree(
142
+ identifier: string,
143
+ repos: RepoConfig[],
144
+ opts?: { baseDir?: string },
145
+ ): MultiWorktreeResult {
146
+ const baseDir = resolveBaseDir(opts?.baseDir);
147
+ const parentPath = path.join(baseDir, identifier);
148
+
149
+ // Ensure parent directory exists
150
+ if (!existsSync(parentPath)) {
151
+ mkdirSync(parentPath, { recursive: true });
152
+ }
153
+
154
+ const branch = `codex/${identifier}`;
155
+ const worktrees: MultiWorktreeResult["worktrees"] = [];
156
+
157
+ for (const repo of repos) {
158
+ if (!existsSync(repo.path)) {
159
+ throw new Error(`Repo not found: ${repo.name} at ${repo.path}`);
160
+ }
161
+
162
+ const worktreePath = path.join(parentPath, repo.name);
163
+
164
+ // Fetch latest from origin (best effort)
165
+ try {
166
+ git(["fetch", "origin"], repo.path);
167
+ } catch {
168
+ // Offline or no remote — continue with local state
169
+ }
170
+
171
+ // Idempotent: if worktree already exists, resume it
172
+ if (existsSync(worktreePath)) {
173
+ try {
174
+ git(["rev-parse", "--git-dir"], worktreePath);
175
+ ensureGitignore(worktreePath);
176
+ worktrees.push({ repoName: repo.name, path: worktreePath, branch, resumed: true });
177
+ continue;
178
+ } catch {
179
+ // Directory exists but isn't a valid worktree — remove and recreate
180
+ try {
181
+ git(["worktree", "remove", "--force", worktreePath], repo.path);
182
+ } catch { /* best effort */ }
183
+ }
184
+ }
185
+
186
+ // Check if branch already exists (resume scenario)
187
+ const exists = branchExistsInRepo(branch, repo.path);
188
+
189
+ if (exists) {
190
+ git(["worktree", "add", worktreePath, branch], repo.path);
191
+ ensureGitignore(worktreePath);
192
+ worktrees.push({ repoName: repo.name, path: worktreePath, branch, resumed: true });
193
+ } else {
194
+ git(["worktree", "add", "-b", branch, worktreePath], repo.path);
195
+ ensureGitignore(worktreePath);
196
+ worktrees.push({ repoName: repo.name, path: worktreePath, branch, resumed: false });
197
+ }
198
+ }
199
+
200
+ return { parentPath, worktrees };
201
+ }
202
+
120
203
  /**
121
204
  * Check if a branch exists in the repo.
122
205
  */
@@ -0,0 +1,156 @@
1
+ /**
2
+ * commands.ts — Zero-LLM slash commands for dispatch operations.
3
+ *
4
+ * Registered via api.registerCommand(). These commands bypass the AI agent
5
+ * entirely — they read/write dispatch state directly and return formatted text.
6
+ */
7
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
8
+ import {
9
+ readDispatchState,
10
+ getActiveDispatch,
11
+ listActiveDispatches,
12
+ removeActiveDispatch,
13
+ transitionDispatch,
14
+ TransitionError,
15
+ registerDispatch,
16
+ type ActiveDispatch,
17
+ } from "../pipeline/dispatch-state.js";
18
+
19
+ export function registerDispatchCommands(api: OpenClawPluginApi): void {
20
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
21
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
22
+
23
+ api.registerCommand({
24
+ name: "dispatch",
25
+ description: "Manage dispatches: list, status <id>, retry <id>, escalate <id>",
26
+ acceptsArgs: true,
27
+ handler: async (ctx) => {
28
+ const args = (ctx.args ?? "").trim().split(/\s+/);
29
+ const sub = args[0]?.toLowerCase();
30
+ const id = args[1];
31
+
32
+ if (!sub || sub === "list") {
33
+ return await handleList(statePath);
34
+ }
35
+ if (sub === "status" && id) {
36
+ return await handleStatus(id, statePath);
37
+ }
38
+ if (sub === "retry" && id) {
39
+ return await handleRetry(id, statePath, api);
40
+ }
41
+ if (sub === "escalate" && id) {
42
+ const reason = args.slice(2).join(" ") || "manual escalation";
43
+ return await handleEscalate(id, reason, statePath, api);
44
+ }
45
+
46
+ return {
47
+ text: [
48
+ "**Dispatch Commands:**",
49
+ "`/dispatch list` — show active dispatches",
50
+ "`/dispatch status <id>` — phase/attempt details",
51
+ "`/dispatch retry <id>` — reset stuck → dispatched",
52
+ "`/dispatch escalate <id> [reason]` — force to stuck",
53
+ ].join("\n"),
54
+ };
55
+ },
56
+ });
57
+ }
58
+
59
+ async function handleList(statePath?: string) {
60
+ const state = await readDispatchState(statePath);
61
+ const active = listActiveDispatches(state);
62
+
63
+ if (active.length === 0) {
64
+ return { text: "No active dispatches." };
65
+ }
66
+
67
+ const lines = active.map((d) => {
68
+ const age = Math.round((Date.now() - new Date(d.dispatchedAt).getTime()) / 60_000);
69
+ return `**${d.issueIdentifier}** — ${d.status} (${d.tier}, attempt ${d.attempt}, ${age}m)`;
70
+ });
71
+
72
+ return { text: `**Active Dispatches (${active.length})**\n${lines.join("\n")}` };
73
+ }
74
+
75
+ async function handleStatus(id: string, statePath?: string) {
76
+ const state = await readDispatchState(statePath);
77
+ const d = getActiveDispatch(state, id);
78
+
79
+ if (!d) {
80
+ const completed = state.dispatches.completed[id];
81
+ if (completed) {
82
+ return {
83
+ text: `**${id}** — completed (${completed.status}, ${completed.tier}, ${completed.totalAttempts ?? 0} attempts)`,
84
+ };
85
+ }
86
+ return { text: `No dispatch found for ${id}.` };
87
+ }
88
+
89
+ const age = Math.round((Date.now() - new Date(d.dispatchedAt).getTime()) / 60_000);
90
+ const lines = [
91
+ `**${d.issueIdentifier}** — ${d.issueTitle ?? d.issueIdentifier}`,
92
+ `Status: ${d.status} | Tier: ${d.tier} | Attempt: ${d.attempt}`,
93
+ `Age: ${age}m | Worktree: \`${d.worktreePath}\``,
94
+ d.stuckReason ? `Stuck reason: ${d.stuckReason}` : "",
95
+ d.agentSessionId ? `Session: ${d.agentSessionId}` : "",
96
+ ].filter(Boolean);
97
+
98
+ return { text: lines.join("\n") };
99
+ }
100
+
101
+ async function handleRetry(id: string, statePath: string | undefined, api: OpenClawPluginApi) {
102
+ const state = await readDispatchState(statePath);
103
+ const d = getActiveDispatch(state, id);
104
+
105
+ if (!d) {
106
+ return { text: `No active dispatch found for ${id}.` };
107
+ }
108
+
109
+ if (d.status !== "stuck") {
110
+ return { text: `Cannot retry ${id} — status is ${d.status} (must be stuck).` };
111
+ }
112
+
113
+ // Remove and re-register with reset status
114
+ await removeActiveDispatch(id, statePath);
115
+ const retryDispatch: ActiveDispatch = {
116
+ ...d,
117
+ status: "dispatched",
118
+ stuckReason: undefined,
119
+ workerSessionKey: undefined,
120
+ auditSessionKey: undefined,
121
+ dispatchedAt: new Date().toISOString(),
122
+ };
123
+ await registerDispatch(id, retryDispatch, statePath);
124
+
125
+ api.logger.info(`/dispatch retry: ${id} reset from stuck → dispatched`);
126
+ return { text: `**${id}** reset to dispatched. Will be picked up by next dispatch cycle.` };
127
+ }
128
+
129
+ async function handleEscalate(
130
+ id: string,
131
+ reason: string,
132
+ statePath: string | undefined,
133
+ api: OpenClawPluginApi,
134
+ ) {
135
+ const state = await readDispatchState(statePath);
136
+ const d = getActiveDispatch(state, id);
137
+
138
+ if (!d) {
139
+ return { text: `No active dispatch found for ${id}.` };
140
+ }
141
+
142
+ if (d.status === "stuck" || d.status === "done" || d.status === "failed") {
143
+ return { text: `Cannot escalate ${id} — already in terminal state: ${d.status}.` };
144
+ }
145
+
146
+ try {
147
+ await transitionDispatch(id, d.status, "stuck", { stuckReason: reason }, statePath);
148
+ api.logger.info(`/dispatch escalate: ${id} → stuck (${reason})`);
149
+ return { text: `**${id}** escalated to stuck: ${reason}` };
150
+ } catch (err) {
151
+ if (err instanceof TransitionError) {
152
+ return { text: `CAS conflict: ${err.message}` };
153
+ }
154
+ return { text: `Error: ${String(err)}` };
155
+ }
156
+ }
@@ -258,12 +258,12 @@ describe("checkConnectivity", () => {
258
258
  expect(apiCheck?.severity).toBe("pass");
259
259
  });
260
260
 
261
- it("reports Discord not configured as pass", async () => {
261
+ it("reports notifications not configured as pass", async () => {
262
262
  vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("should not be called"); }));
263
263
  const checks = await checkConnectivity({});
264
- const discordCheck = checks.find((c) => c.label.includes("Discord"));
265
- expect(discordCheck?.severity).toBe("pass");
266
- expect(discordCheck?.label).toContain("not configured");
264
+ const notifCheck = checks.find((c) => c.label.includes("Notifications"));
265
+ expect(notifCheck?.severity).toBe("pass");
266
+ expect(notifCheck?.label).toContain("not configured");
267
267
  });
268
268
 
269
269
  it("reports webhook skip when gateway not running", async () => {
@@ -530,36 +530,14 @@ export async function checkConnectivity(pluginConfig?: Record<string, unknown>,
530
530
  }
531
531
  }
532
532
 
533
- // Discord notifications
534
- const flowDiscordChannel = pluginConfig?.flowDiscordChannel as string | undefined;
535
- if (!flowDiscordChannel) {
536
- checks.push(pass("Discord notifications: not configured (skipped)"));
533
+ // Notification targets
534
+ const notifRaw = pluginConfig?.notifications as { targets?: { channel: string; target: string }[] } | undefined;
535
+ const notifTargets = notifRaw?.targets ?? [];
536
+ if (notifTargets.length === 0) {
537
+ checks.push(pass("Notifications: not configured (skipped)"));
537
538
  } else {
538
- // Read Discord bot token from openclaw.json
539
- let discordBotToken: string | undefined;
540
- try {
541
- const configPath = join(homedir(), ".openclaw", "openclaw.json");
542
- const config = JSON.parse(readFileSync(configPath, "utf8"));
543
- discordBotToken = config?.channels?.discord?.token as string | undefined;
544
- } catch {
545
- // Can't read config
546
- }
547
-
548
- if (!discordBotToken) {
549
- checks.push(warn("Discord notifications: no bot token found in openclaw.json"));
550
- } else {
551
- try {
552
- const res = await fetch(`https://discord.com/api/v10/channels/${flowDiscordChannel}`, {
553
- headers: { Authorization: `Bot ${discordBotToken}` },
554
- });
555
- if (res.ok) {
556
- checks.push(pass("Discord notifications: enabled"));
557
- } else {
558
- checks.push(warn(`Discord channel check: ${res.status} ${res.statusText}`));
559
- }
560
- } catch (err) {
561
- checks.push(warn(`Discord API unreachable: ${err instanceof Error ? err.message : String(err)}`));
562
- }
539
+ for (const t of notifTargets) {
540
+ checks.push(pass(`Notifications: ${t.channel} ${t.target}`));
563
541
  }
564
542
  }
565
543