@chrysb/alphaclaw 0.4.5 → 0.4.6-beta.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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
  <h1 align="center">AlphaClaw</h1>
5
5
  <p align="center">
6
- <strong>The ops layer for OpenClaw. Deploy in minutes. Stay running for months.</strong><br>
6
+ <strong>The ultimate OpenClaw harness. Deploy in minutes. Stay running for months.</strong><br>
7
7
  <strong>Observability. Reliability. Agent discipline. Zero SSH rescue missions.</strong>
8
8
  </p>
9
9
 
@@ -13,7 +13,7 @@
13
13
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
14
14
  </p>
15
15
 
16
- <p align="center">AlphaClaw wraps <a href="https://github.com/openclaw/openclaw">OpenClaw</a> with a convenient setup wizard, self-healing watchdog, Git-backed rollback, and full browser-based observability. Ships with anti-drift prompt hardening to keep your agent disciplined, and simplifies integrations (e.g. Google Workspace, Telegram Topics) so you can manage everything from one UI instead of config files.</p>
16
+ <p align="center">AlphaClaw wraps <a href="https://github.com/openclaw/openclaw">OpenClaw</a> with a convenient setup wizard, self-healing watchdog, Git-backed rollback, and full browser-based observability. Ships with anti-drift prompt hardening to keep your agent disciplined, and simplifies integrations (e.g. Google Workspace, Google Pub/Sub, Telegram Topics) so you can manage everything from one UI instead of config files.</p>
17
17
 
18
18
  <p align="center"><em>First deploy to first message in under five minutes.</em></p>
19
19
 
@@ -32,7 +32,8 @@
32
32
  - **Watchdog:** Crash detection, crash-loop recovery, auto-repair (`openclaw doctor --fix`), and Telegram/Discord notifications.
33
33
  - **Channel Orchestration:** Telegram and Discord bot pairing, credential sync, and a guided wizard for splitting Telegram into multi-threaded topic groups as your usage grows.
34
34
  - **Webhooks:** Named webhook endpoints with per-hook transform modules, request logging, and payload inspection.
35
- - **Google Workspace:** OAuth integration for Gmail, Calendar, Drive, Docs, Sheets, Tasks, Contacts, and Meet.
35
+ - **Google Workspace:** OAuth integration for Gmail, Calendar, Drive, Docs, Sheets, Tasks, Contacts, and Meet, plus guided Gmail watch setup with Google Pub/Sub topic, subscription, and push endpoint handling.
36
+ - **File Explorer:** Browser-based workspace explorer with file visibility, inline edits, diff view, and Git-aware sync for quick fixes without SSH.
36
37
  - **Prompt Hardening:** Ships anti-drift bootstrap prompts (`AGENTS.md`, `TOOLS.md`) injected into your agent's system prompt on every message — enforcing safe practices, commit discipline, and change summaries out of the box.
37
38
  - **Git Sync:** Automatic hourly commits of your OpenClaw workspace to GitHub with configurable cron schedule. Combined with prompt hardening, every agent action is version-controlled and auditable.
38
39
  - **Version Management:** In-place updates for both AlphaClaw and OpenClaw with changelog review and one-click apply.
@@ -81,13 +82,15 @@ CMD ["alphaclaw", "start"]
81
82
 
82
83
  ## Setup UI
83
84
 
84
- | Tab | What it manages |
85
- | ------------- | ---------------------------------------------------------------------------------------------------------- |
86
- | **General** | Gateway status, channel health, pending pairings, Google Workspace, repo sync schedule, OpenClaw dashboard |
87
- | **Watchdog** | Health monitoring, crash-loop status, auto-repair toggle, notifications toggle, event log, live log tail |
88
- | **Providers** | AI provider credentials (Anthropic, OpenAI, Gemini, Mistral, Voyage, Groq, Deepgram) and model selection |
89
- | **Envars** | Environment variables view, edit, add with gateway restart prompts |
90
- | **Webhooks** | Webhook endpoints, transform modules, request history, payload inspection |
85
+ | Tab | What it manages |
86
+ | ------------- | --------------------------------------------------------------------------------------------------------------- |
87
+ | **General** | Gateway status, channel health, pending pairings, Google Workspace, repo sync schedule, OpenClaw dashboard |
88
+ | **Browse** | File explorer for workspace visibility, inline edits, diff review, and Git-backed sync |
89
+ | **Usage** | Token summaries, per-session and per-agent cost and token breakdown |
90
+ | **Watchdog** | Health monitoring, crash-loop status, auto-repair toggle, notifications toggle, event log, live log tail |
91
+ | **Providers** | AI provider credentials (Anthropic, OpenAI, Gemini, Mistral, Voyage, Groq, Deepgram) and model selection |
92
+ | **Envars** | Environment variables — view, edit, add — with gateway restart prompts |
93
+ | **Webhooks** | Webhook endpoints, transform modules, request history, payload inspection, including Gmail watch delivery flows |
91
94
 
92
95
  ## CLI
93
96
 
@@ -121,14 +124,14 @@ graph TD
121
124
 
122
125
  The built-in watchdog monitors gateway health and recovers from failures automatically.
123
126
 
124
- | Capability | Details |
125
- | --------------------------- | -------------------------------------------------------------- |
126
- | **Health checks** | Periodic `openclaw health` with configurable interval |
127
- | **Crash detection** | Listens for gateway exit events |
128
- | **Crash-loop detection** | Threshold-based (default: 3 crashes in 300s) |
129
- | **Auto-repair** | Runs `openclaw doctor --fix --yes`, relaunches gateway |
130
- | **Notifications** | Telegram and Discord alerts for crashes, repairs, and recovery |
131
- | **Event log** | SQLite-backed incident history with API and UI access |
127
+ | Capability | Details |
128
+ | ------------------------ | -------------------------------------------------------------- |
129
+ | **Health checks** | Periodic `openclaw health` with configurable interval |
130
+ | **Crash detection** | Listens for gateway exit events |
131
+ | **Crash-loop detection** | Threshold-based (default: 3 crashes in 300s) |
132
+ | **Auto-repair** | Runs `openclaw doctor --fix --yes`, relaunches gateway |
133
+ | **Notifications** | Telegram and Discord alerts for crashes, repairs, and recovery |
134
+ | **Event log** | SQLite-backed incident history with API and UI access |
132
135
 
133
136
  ## Environment Variables
134
137
 
@@ -53,6 +53,14 @@ body::before {
53
53
  color: var(--text-muted);
54
54
  }
55
55
 
56
+ .ac-small-heading {
57
+ font-size: 11px;
58
+ font-weight: 500;
59
+ letter-spacing: 0.08em;
60
+ text-transform: uppercase;
61
+ color: var(--text-muted);
62
+ }
63
+
56
64
  /* Shared collapsible history rows (incidents, webhook requests). */
57
65
  .ac-history-list {
58
66
  display: flex;
@@ -398,6 +406,22 @@ textarea:focus {
398
406
  }
399
407
  }
400
408
 
409
+ @keyframes acStatusDotPulseInfo {
410
+ 0%,
411
+ 100% {
412
+ transform: scale(1);
413
+ box-shadow:
414
+ 0 0 0 0 rgba(34, 211, 238, 0.16),
415
+ 0 0 5px rgba(34, 211, 238, 0.22);
416
+ }
417
+ 50% {
418
+ transform: scale(1.08);
419
+ box-shadow:
420
+ 0 0 0 3px rgba(34, 211, 238, 0.1),
421
+ 0 0 9px rgba(34, 211, 238, 0.34);
422
+ }
423
+ }
424
+
401
425
  .ac-status-dot {
402
426
  width: 8px;
403
427
  height: 8px;
@@ -409,6 +433,11 @@ textarea:focus {
409
433
  animation: acStatusDotPulse 2.6s ease-in-out infinite;
410
434
  }
411
435
 
436
+ .ac-status-dot--info {
437
+ background: #22d3ee;
438
+ animation: acStatusDotPulseInfo 2.6s ease-in-out infinite;
439
+ }
440
+
412
441
  .ac-status-dot--healthy-offset {
413
442
  animation-delay: 0.95s;
414
443
  }
@@ -20,6 +20,7 @@ import {
20
20
  fetchRestartStatus,
21
21
  restartGateway,
22
22
  fetchWatchdogStatus,
23
+ fetchDoctorStatus,
23
24
  triggerWatchdogRepair,
24
25
  updateOpenclaw,
25
26
  } from "./lib/api.js";
@@ -44,6 +45,8 @@ import { WatchdogTab } from "./components/watchdog-tab.js";
44
45
  import { FileViewer } from "./components/file-viewer/index.js";
45
46
  import { AppSidebar } from "./components/sidebar.js";
46
47
  import { UsageTab } from "./components/usage-tab/index.js";
48
+ import { DoctorTab } from "./components/doctor/index.js";
49
+ import { GeneralDoctorWarning } from "./components/doctor/general-warning.js";
47
50
  import { readUiSettings, writeUiSettings } from "./lib/ui-settings.js";
48
51
  const html = htm.bind(h);
49
52
  const kDefaultUiTab = "general";
@@ -52,6 +55,8 @@ const kSidebarMinWidthPx = 180;
52
55
  const kSidebarMaxWidthPx = 460;
53
56
  const kBrowseLastPathUiSettingKey = "browseLastPath";
54
57
  const kLastMenuRouteUiSettingKey = "lastMenuRoute";
58
+ const kDoctorWarningDismissedUntilUiSettingKey = "doctorWarningDismissedUntilMs";
59
+ const kOneWeekMs = 7 * 24 * 60 * 60 * 1000;
55
60
  const kBrowseRestartRequiredRules = [
56
61
  { type: "file", path: "openclaw.json" },
57
62
  { type: "directory", path: "hooks/transforms" },
@@ -121,6 +126,8 @@ const RouteRedirect = ({ to }) => {
121
126
  const GeneralTab = ({
122
127
  statusData = null,
123
128
  watchdogData = null,
129
+ doctorStatusData = null,
130
+ doctorWarningDismissedUntilMs = 0,
124
131
  onRefreshStatuses = () => {},
125
132
  onSwitchTab,
126
133
  onNavigate,
@@ -133,11 +140,13 @@ const GeneralTab = ({
133
140
  onOpenclawVersionActionComplete = () => {},
134
141
  onOpenclawUpdate,
135
142
  onRestartRequired = () => {},
143
+ onDismissDoctorWarning = () => {},
136
144
  }) => {
137
145
  const [dashboardLoading, setDashboardLoading] = useState(false);
138
146
  const [repairingWatchdog, setRepairingWatchdog] = useState(false);
139
147
  const status = statusData;
140
148
  const watchdogStatus = watchdogData;
149
+ const doctorStatus = doctorStatusData;
141
150
  const gatewayStatus = status?.gateway ?? null;
142
151
  const channels = status?.channels ?? null;
143
152
  const repo = status?.repo || null;
@@ -297,6 +306,12 @@ const GeneralTab = ({
297
306
  onOpenclawVersionActionComplete=${onOpenclawVersionActionComplete}
298
307
  onOpenclawUpdate=${onOpenclawUpdate}
299
308
  />
309
+ <${GeneralDoctorWarning}
310
+ doctorStatus=${doctorStatus}
311
+ dismissedUntilMs=${doctorWarningDismissedUntilMs}
312
+ onOpenDoctor=${() => onSwitchTab("doctor")}
313
+ onDismiss=${onDismissDoctorWarning}
314
+ />
300
315
  <${Channels} channels=${channels} onSwitchTab=${onSwitchTab} onNavigate=${onNavigate} />
301
316
  <${Pairings}
302
317
  pending=${pending}
@@ -446,6 +461,10 @@ const App = () => {
446
461
  }
447
462
  return `/${kDefaultUiTab}`;
448
463
  });
464
+ const [doctorWarningDismissedUntilMs, setDoctorWarningDismissedUntilMs] = useState(() => {
465
+ const settings = readUiSettings();
466
+ return Number(settings[kDoctorWarningDismissedUntilUiSettingKey] || 0);
467
+ });
449
468
  const [isResizingSidebar, setIsResizingSidebar] = useState(false);
450
469
  const [browsePreviewPath, setBrowsePreviewPath] = useState("");
451
470
  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
@@ -464,13 +483,18 @@ const App = () => {
464
483
  const sharedWatchdogPoll = usePolling(fetchWatchdogStatus, statusPollCadenceMs, {
465
484
  enabled: onboarded === true,
466
485
  });
486
+ const sharedDoctorPoll = usePolling(fetchDoctorStatus, statusPollCadenceMs, {
487
+ enabled: onboarded === true,
488
+ });
467
489
  const sharedStatus = sharedStatusPoll.data || null;
468
490
  const sharedWatchdogStatus = sharedWatchdogPoll.data?.status || null;
491
+ const sharedDoctorStatus = sharedDoctorPoll.data?.status || null;
469
492
  const isAnyRestartRequired = restartRequired || browseRestartRequired;
470
493
  const refreshSharedStatuses = useCallback(() => {
471
494
  sharedStatusPoll.refresh();
472
495
  sharedWatchdogPoll.refresh();
473
- }, [sharedStatusPoll.refresh, sharedWatchdogPoll.refresh]);
496
+ sharedDoctorPoll.refresh();
497
+ }, [sharedStatusPoll.refresh, sharedWatchdogPoll.refresh, sharedDoctorPoll.refresh]);
474
498
 
475
499
  const closeMenu = useCallback((e) => {
476
500
  if (menuRef.current && !menuRef.current.contains(e.target)) {
@@ -762,6 +786,7 @@ const App = () => {
762
786
  items: [
763
787
  { id: "watchdog", label: "Watchdog" },
764
788
  { id: "usage", label: "Usage" },
789
+ { id: "doctor", label: "Doctor" },
765
790
  ],
766
791
  },
767
792
  {
@@ -810,6 +835,8 @@ const App = () => {
810
835
  ? "watchdog"
811
836
  : location.startsWith("/usage")
812
837
  ? "usage"
838
+ : location.startsWith("/doctor")
839
+ ? "doctor"
813
840
  : location.startsWith("/envars")
814
841
  ? "envars"
815
842
  : location.startsWith("/webhooks")
@@ -882,8 +909,9 @@ const App = () => {
882
909
  settings.sidebarWidthPx = sidebarWidthPx;
883
910
  settings[kBrowseLastPathUiSettingKey] = lastBrowsePath;
884
911
  settings[kLastMenuRouteUiSettingKey] = lastMenuRoute;
912
+ settings[kDoctorWarningDismissedUntilUiSettingKey] = doctorWarningDismissedUntilMs;
885
913
  writeUiSettings(settings);
886
- }, [sidebarWidthPx, lastBrowsePath, lastMenuRoute]);
914
+ }, [sidebarWidthPx, lastBrowsePath, lastMenuRoute, doctorWarningDismissedUntilMs]);
887
915
 
888
916
  const resizeSidebarWithClientX = useCallback((clientX) => {
889
917
  const shellElement = appShellRef.current;
@@ -1039,6 +1067,8 @@ const App = () => {
1039
1067
  <${GeneralTab}
1040
1068
  statusData=${sharedStatus}
1041
1069
  watchdogData=${sharedWatchdogStatus}
1070
+ doctorStatusData=${sharedDoctorStatus}
1071
+ doctorWarningDismissedUntilMs=${doctorWarningDismissedUntilMs}
1042
1072
  onRefreshStatuses=${refreshSharedStatuses}
1043
1073
  onSwitchTab=${(nextTab) => setLocation(`/${nextTab}`)}
1044
1074
  onNavigate=${navigateToSubScreen}
@@ -1051,6 +1081,8 @@ const App = () => {
1051
1081
  onOpenclawVersionActionComplete=${handleOpenclawVersionActionComplete}
1052
1082
  onOpenclawUpdate=${handleOpenclawUpdate}
1053
1083
  onRestartRequired=${setRestartRequired}
1084
+ onDismissDoctorWarning=${() =>
1085
+ setDoctorWarningDismissedUntilMs(Date.now() + kOneWeekMs)}
1054
1086
  />
1055
1087
  </div>
1056
1088
  </div>
@@ -1104,6 +1136,13 @@ const App = () => {
1104
1136
  />
1105
1137
  </div>
1106
1138
  </${Route}>
1139
+ <${Route} path="/doctor">
1140
+ <div class="pt-4">
1141
+ <${DoctorTab}
1142
+ isActive=${location === "/doctor"}
1143
+ />
1144
+ </div>
1145
+ </${Route}>
1107
1146
  <${Route} path="/envars">
1108
1147
  <div class="pt-4">
1109
1148
  <${Envars} onRestartRequired=${setRestartRequired} />
@@ -8,6 +8,10 @@ const kToneClasses = {
8
8
  warning: "bg-yellow-500/10 text-yellow-500",
9
9
  danger: "bg-red-500/10 text-red-400",
10
10
  neutral: "bg-gray-500/10 text-gray-400",
11
+ info: "bg-blue-500/10 text-blue-400",
12
+ accent: "bg-purple-500/10 text-purple-400",
13
+ cyan: "bg-cyan-500/10 text-cyan-400",
14
+ secondary: "bg-indigo-500/10 text-indigo-300",
11
15
  };
12
16
 
13
17
  export const Badge = ({ tone = "neutral", children }) => html`
@@ -0,0 +1,195 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { Badge } from "../badge.js";
4
+ import { ActionButton } from "../action-button.js";
5
+ import {
6
+ formatDoctorCategory,
7
+ getDoctorCategoryTone,
8
+ getDoctorPriorityTone,
9
+ } from "./helpers.js";
10
+
11
+ const html = htm.bind(h);
12
+
13
+ const renderEvidenceLine = (item = {}) => {
14
+ if (item?.path) return item.path;
15
+ if (item?.text) return item.text;
16
+ return JSON.stringify(item);
17
+ };
18
+
19
+ export const DoctorFindingsList = ({
20
+ cards = [],
21
+ busyCardId = 0,
22
+ onAskAgentFix = () => {},
23
+ onUpdateStatus = () => {},
24
+ showRunMeta = false,
25
+ hideEmptyState = false,
26
+ }) => {
27
+ return html`
28
+ <div class="space-y-4">
29
+ ${cards.length
30
+ ? html`
31
+ <div class="space-y-3">
32
+ ${cards.map(
33
+ (card) => html`
34
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
35
+ <div class="space-y-2">
36
+ <div class="flex flex-wrap items-start justify-between gap-3">
37
+ <div class="space-y-2 min-w-0">
38
+ <div class="flex flex-wrap items-center gap-2">
39
+ <${Badge} tone=${getDoctorPriorityTone(card.priority)}>
40
+ ${card.priority}
41
+ </${Badge}>
42
+ <h3 class="text-sm font-semibold text-gray-100">
43
+ ${card.title}
44
+ </h3>
45
+ </div>
46
+ <div class="flex flex-wrap items-center gap-2">
47
+ <${Badge} tone=${getDoctorCategoryTone(card.category)}>
48
+ ${formatDoctorCategory(card.category)}
49
+ </${Badge}>
50
+ ${
51
+ showRunMeta
52
+ ? html`
53
+ <span class="text-xs text-gray-600"
54
+ >Run #${card.runId}</span
55
+ >
56
+ `
57
+ : null
58
+ }
59
+ </div>
60
+ </div>
61
+ </div>
62
+ ${
63
+ card.summary
64
+ ? html`<p
65
+ class="text-xs text-gray-300 leading-5 pt-1"
66
+ >
67
+ ${card.summary}
68
+ </p>`
69
+ : null
70
+ }
71
+ </div>
72
+ <details class="group rounded-lg border border-border bg-black/20">
73
+ <summary class="list-none cursor-pointer px-3 py-2.5 text-xs text-gray-400 group-open:border-b group-open:border-border">
74
+ <span class="inline-flex items-center gap-2">
75
+ <span
76
+ class="inline-block text-gray-500 transition-transform duration-200 group-open:rotate-90"
77
+ aria-hidden="true"
78
+ >▸</span
79
+ >
80
+ <span>Show recommendation and details</span>
81
+ </span>
82
+ </summary>
83
+ <div class="p-3 space-y-3">
84
+ <div>
85
+ <div class="ac-small-heading">
86
+ Recommendation
87
+ </div>
88
+ <p class="text-xs text-gray-200 mt-1 leading-5">
89
+ ${card.recommendation}
90
+ </p>
91
+ </div>
92
+ ${
93
+ Array.isArray(card.targetPaths) &&
94
+ card.targetPaths.length
95
+ ? html`
96
+ <div>
97
+ <div class="ac-small-heading">
98
+ Target paths
99
+ </div>
100
+ <div class="mt-1 flex flex-wrap gap-1.5">
101
+ ${card.targetPaths.map(
102
+ (targetPath) => html`
103
+ <span
104
+ class="text-[11px] px-2 py-1 rounded-md bg-black/30 border border-border font-mono text-gray-300"
105
+ >
106
+ ${targetPath}
107
+ </span>
108
+ `,
109
+ )}
110
+ </div>
111
+ </div>
112
+ `
113
+ : null
114
+ }
115
+ ${
116
+ Array.isArray(card.evidence) && card.evidence.length
117
+ ? html`
118
+ <div>
119
+ <div class="ac-small-heading">Evidence</div>
120
+ <div class="mt-1 space-y-1">
121
+ ${card.evidence.map(
122
+ (item) => html`
123
+ <div class="text-xs text-gray-400">
124
+ ${renderEvidenceLine(item)}
125
+ </div>
126
+ `,
127
+ )}
128
+ </div>
129
+ </div>
130
+ `
131
+ : null
132
+ }
133
+ </div>
134
+ </details>
135
+ <div class="flex flex-wrap gap-2">
136
+ <${ActionButton}
137
+ onClick=${() => onAskAgentFix(card)}
138
+ loading=${busyCardId === card.id}
139
+ tone="primary"
140
+ idleLabel="Ask agent to fix"
141
+ loadingLabel="Sending..."
142
+ />
143
+ ${
144
+ card.status !== "fixed"
145
+ ? html`
146
+ <${ActionButton}
147
+ onClick=${() => onUpdateStatus(card, "fixed")}
148
+ tone="secondary"
149
+ idleLabel="Mark fixed"
150
+ />
151
+ `
152
+ : html`
153
+ <${ActionButton}
154
+ onClick=${() => onUpdateStatus(card, "open")}
155
+ tone="secondary"
156
+ idleLabel="Reopen"
157
+ />
158
+ `
159
+ }
160
+ ${
161
+ card.status !== "dismissed"
162
+ ? html`
163
+ <${ActionButton}
164
+ onClick=${() =>
165
+ onUpdateStatus(card, "dismissed")}
166
+ tone="ghost"
167
+ idleLabel="Dismiss"
168
+ />
169
+ `
170
+ : html`
171
+ <${ActionButton}
172
+ onClick=${() => onUpdateStatus(card, "open")}
173
+ tone="ghost"
174
+ idleLabel="Restore"
175
+ />
176
+ `
177
+ }
178
+ </div>
179
+ </div>
180
+ `,
181
+ )}
182
+ </div>
183
+ `
184
+ : hideEmptyState
185
+ ? null
186
+ : html`
187
+ <div class="ac-surface-inset rounded-xl p-4 space-y-1.5">
188
+ <p class="text-xs text-gray-300 leading-5">
189
+ No findings currently for this selection.
190
+ </p>
191
+ </div>
192
+ `}
193
+ </div>
194
+ `;
195
+ };
@@ -0,0 +1,186 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { ModalShell } from "../modal-shell.js";
5
+ import { ActionButton } from "../action-button.js";
6
+ import { Badge } from "../badge.js";
7
+ import {
8
+ fetchAgentSessions,
9
+ sendDoctorCardFix,
10
+ updateDoctorCardStatus,
11
+ } from "../../lib/api.js";
12
+ import { showToast } from "../toast.js";
13
+ import { getDoctorPriorityTone } from "./helpers.js";
14
+
15
+ const html = htm.bind(h);
16
+
17
+ export const DoctorFixCardModal = ({
18
+ visible = false,
19
+ card = null,
20
+ onClose = () => {},
21
+ onComplete = () => {},
22
+ }) => {
23
+ const [sessions, setSessions] = useState([]);
24
+ const [selectedSessionKey, setSelectedSessionKey] = useState("");
25
+ const [loadingSessions, setLoadingSessions] = useState(false);
26
+ const [sending, setSending] = useState(false);
27
+ const [loadError, setLoadError] = useState("");
28
+ const [promptText, setPromptText] = useState("");
29
+
30
+ useEffect(() => {
31
+ if (!visible) return;
32
+ setPromptText(String(card?.fixPrompt || ""));
33
+ }, [visible, card?.fixPrompt, card?.id]);
34
+
35
+ useEffect(() => {
36
+ if (!visible) return;
37
+ let active = true;
38
+ const loadSessions = async () => {
39
+ try {
40
+ setLoadingSessions(true);
41
+ setLoadError("");
42
+ const data = await fetchAgentSessions();
43
+ if (!active) return;
44
+ const nextSessions = Array.isArray(data?.sessions) ? data.sessions : [];
45
+ setSessions(nextSessions);
46
+ const preferredSession =
47
+ nextSessions.find((sessionRow) => {
48
+ const key = String(sessionRow?.key || "").toLowerCase();
49
+ return key === "agent:main:main";
50
+ }) ||
51
+ nextSessions.find((sessionRow) => {
52
+ const key = String(sessionRow?.key || "").toLowerCase();
53
+ return key.includes(":direct:") || key.includes(":group:");
54
+ }) ||
55
+ nextSessions[0] ||
56
+ null;
57
+ setSelectedSessionKey(String(preferredSession?.key || ""));
58
+ } catch (error) {
59
+ if (!active) return;
60
+ setSessions([]);
61
+ setSelectedSessionKey("");
62
+ setLoadError(error.message || "Could not load agent sessions");
63
+ } finally {
64
+ if (active) setLoadingSessions(false);
65
+ }
66
+ };
67
+ loadSessions();
68
+ return () => {
69
+ active = false;
70
+ };
71
+ }, [visible, card?.id]);
72
+
73
+ const selectedSession = useMemo(
74
+ () =>
75
+ sessions.find(
76
+ (sessionRow) => String(sessionRow?.key || "") === selectedSessionKey,
77
+ ) || null,
78
+ [sessions, selectedSessionKey],
79
+ );
80
+
81
+ const handleSend = async () => {
82
+ if (!card?.id || sending) return;
83
+ try {
84
+ setSending(true);
85
+ await sendDoctorCardFix({
86
+ cardId: card.id,
87
+ sessionId: selectedSession?.sessionId || "",
88
+ replyChannel: selectedSession?.replyChannel || "",
89
+ replyTo: selectedSession?.replyTo || "",
90
+ prompt: promptText,
91
+ });
92
+ try {
93
+ await updateDoctorCardStatus({ cardId: card.id, status: "fixed" });
94
+ showToast("Doctor fix request sent and finding marked fixed", "success");
95
+ } catch (statusError) {
96
+ showToast(
97
+ statusError.message || "Doctor fix request sent, but could not mark the finding fixed",
98
+ "warning",
99
+ );
100
+ }
101
+ onComplete();
102
+ onClose();
103
+ } catch (error) {
104
+ showToast(error.message || "Could not send Doctor fix request", "error");
105
+ } finally {
106
+ setSending(false);
107
+ }
108
+ };
109
+
110
+ return html`
111
+ <${ModalShell}
112
+ visible=${visible}
113
+ onClose=${onClose}
114
+ panelClassName="bg-modal border border-border rounded-xl p-5 max-w-lg w-full space-y-4"
115
+ >
116
+ <div class="space-y-1">
117
+ <h2 class="text-base font-semibold">Ask agent to fix</h2>
118
+ <p class="text-sm text-gray-400">
119
+ Send this Doctor finding to one of your agent sessions as a focused fix request.
120
+ </p>
121
+ </div>
122
+ <div class="ac-surface-inset p-3 space-y-1">
123
+ <div class="flex items-center gap-2 min-w-0">
124
+ <${Badge} tone=${getDoctorPriorityTone(card?.priority || "P2")}>
125
+ ${card?.priority || "P2"}
126
+ </${Badge}>
127
+ <div class="text-sm font-semibold text-gray-200 leading-5 min-w-0">
128
+ ${card?.title || "Doctor finding"}
129
+ </div>
130
+ </div>
131
+ <div class="text-xs text-gray-400">${card?.recommendation || ""}</div>
132
+ </div>
133
+ <div class="space-y-2">
134
+ <label class="text-xs text-gray-500">Send to session</label>
135
+ <select
136
+ value=${selectedSessionKey}
137
+ onChange=${(event) => setSelectedSessionKey(String(event.currentTarget?.value || ""))}
138
+ disabled=${loadingSessions || sending}
139
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 focus:border-gray-500"
140
+ >
141
+ ${sessions.map(
142
+ (sessionRow) => html`
143
+ <option value=${String(sessionRow?.key || "")}>
144
+ ${String(sessionRow?.label || sessionRow?.key || "Session")}
145
+ </option>
146
+ `,
147
+ )}
148
+ </select>
149
+ ${
150
+ loadingSessions
151
+ ? html`<div class="text-xs text-gray-500">Loading sessions...</div>`
152
+ : null
153
+ }
154
+ ${loadError ? html`<div class="text-xs text-red-400">${loadError}</div>` : null}
155
+ </div>
156
+ <div class="space-y-2">
157
+ <label class="text-xs text-gray-500">Instructions</label>
158
+ <textarea
159
+ value=${promptText}
160
+ onInput=${(event) => setPromptText(String(event.currentTarget?.value || ""))}
161
+ disabled=${sending}
162
+ rows="8"
163
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 focus:border-gray-500 font-mono leading-5"
164
+ ></textarea>
165
+ </div>
166
+ <div class="flex items-center justify-end gap-2">
167
+ <${ActionButton}
168
+ onClick=${onClose}
169
+ disabled=${sending}
170
+ tone="secondary"
171
+ size="md"
172
+ idleLabel="Cancel"
173
+ />
174
+ <${ActionButton}
175
+ onClick=${handleSend}
176
+ disabled=${!selectedSession || loadingSessions || !!loadError || !String(promptText || "").trim()}
177
+ loading=${sending}
178
+ tone="primary"
179
+ size="md"
180
+ idleLabel="Send fix request"
181
+ loadingLabel="Sending..."
182
+ />
183
+ </div>
184
+ </${ModalShell}>
185
+ `;
186
+ };