@chrysb/alphaclaw 0.6.2-beta.4 → 0.6.2-beta.6

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 (40) hide show
  1. package/lib/public/assets/icons/slack.svg +17 -0
  2. package/lib/public/css/cron.css +91 -39
  3. package/lib/public/js/components/add-channel-menu.js +59 -0
  4. package/lib/public/js/components/agents-tab/agent-bindings-section/index.js +14 -38
  5. package/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js +0 -6
  6. package/lib/public/js/components/agents-tab/create-channel-modal.js +185 -47
  7. package/lib/public/js/components/channels.js +15 -44
  8. package/lib/public/js/components/cron-tab/cron-calendar.js +287 -164
  9. package/lib/public/js/components/cron-tab/cron-insights-panel.js +325 -0
  10. package/lib/public/js/components/cron-tab/cron-job-detail.js +38 -363
  11. package/lib/public/js/components/cron-tab/cron-job-settings-card.js +233 -0
  12. package/lib/public/js/components/cron-tab/cron-overview.js +40 -19
  13. package/lib/public/js/components/cron-tab/cron-prompt-editor.js +173 -0
  14. package/lib/public/js/components/cron-tab/cron-run-history-panel.js +69 -56
  15. package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +20 -2
  16. package/lib/public/js/components/cron-tab/index.js +170 -78
  17. package/lib/public/js/components/envars.js +4 -3
  18. package/lib/public/js/components/file-viewer/editor-surface.js +5 -1
  19. package/lib/public/js/components/file-viewer/use-editor-line-number-sync.js +36 -0
  20. package/lib/public/js/components/file-viewer/use-file-viewer.js +7 -23
  21. package/lib/public/js/components/file-viewer/utils.js +1 -5
  22. package/lib/public/js/components/onboarding/pairing-utils.js +1 -0
  23. package/lib/public/js/components/onboarding/welcome-config.js +31 -1
  24. package/lib/public/js/components/onboarding/welcome-form-step.js +145 -67
  25. package/lib/public/js/components/onboarding/welcome-pairing-step.js +89 -50
  26. package/lib/public/js/components/pairings.js +1 -1
  27. package/lib/public/js/components/welcome/index.js +1 -0
  28. package/lib/public/js/lib/channel-provider-availability.js +23 -0
  29. package/lib/server/agents/channels.js +110 -6
  30. package/lib/server/agents/shared.js +70 -1
  31. package/lib/server/constants.js +13 -0
  32. package/lib/server/gateway.js +28 -11
  33. package/lib/server/onboarding/openclaw.js +30 -0
  34. package/lib/server/onboarding/validation.js +1 -1
  35. package/lib/server/routes/pairings.js +2 -2
  36. package/lib/server/routes/system.js +9 -2
  37. package/lib/server/slack-api.js +38 -0
  38. package/lib/server/watchdog-notify.js +20 -3
  39. package/lib/server.js +3 -1
  40. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { SecretInput } from "../secret-input.js";
5
5
  import { ActionButton } from "../action-button.js";
6
6
  import { Badge } from "../badge.js";
7
7
  import { SegmentedControl } from "../segmented-control.js";
8
+ import { getChannelMeta } from "../channels.js";
8
9
  import {
9
10
  kGithubFlowFresh,
10
11
  kGithubFlowImport,
@@ -13,6 +14,15 @@ import {
13
14
  } from "./welcome-config.js";
14
15
 
15
16
  const html = htm.bind(h);
17
+ const kChannelAccordionDefs = [
18
+ { id: "telegram", title: "Telegram", fieldKeys: ["TELEGRAM_BOT_TOKEN"] },
19
+ { id: "discord", title: "Discord", fieldKeys: ["DISCORD_BOT_TOKEN"] },
20
+ {
21
+ id: "slack",
22
+ title: "Slack",
23
+ fieldKeys: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
24
+ },
25
+ ];
16
26
 
17
27
  export const WelcomeFormStep = ({
18
28
  activeGroup,
@@ -50,6 +60,7 @@ export const WelcomeFormStep = ({
50
60
  }) => {
51
61
  const [showOptionalOpenai, setShowOptionalOpenai] = useState(false);
52
62
  const [showOptionalGemini, setShowOptionalGemini] = useState(false);
63
+ const [expandedChannels, setExpandedChannels] = useState(() => new Set(["telegram"]));
53
64
  const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh;
54
65
  const freshRepoMode =
55
66
  githubFlow === kGithubFlowImport
@@ -71,6 +82,126 @@ export const WelcomeFormStep = ({
71
82
  setShowOptionalGemini(!vals.GEMINI_API_KEY);
72
83
  }
73
84
  }, [step === totalGroups - 1]);
85
+ useEffect(() => {
86
+ if (activeGroup.id !== "channels") return;
87
+ setExpandedChannels((current) => {
88
+ if (current.size > 0) return current;
89
+ return new Set(["telegram"]);
90
+ });
91
+ }, [activeGroup.id]);
92
+
93
+ const renderStandardField = (field) => html`
94
+ <div class="space-y-1" key=${field.key}>
95
+ <label class="text-xs font-medium text-gray-400">${field.label}</label>
96
+ <${SecretInput}
97
+ key=${field.key}
98
+ value=${vals[field.key] || ""}
99
+ onInput=${(e) => setValue(field.key, e.target.value)}
100
+ placeholder=${activeGroup.id === "github" && field.key === "GITHUB_TOKEN"
101
+ ? githubTokenPlaceholder
102
+ : field.placeholder || ""}
103
+ isSecret=${!field.isText}
104
+ inputClass="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
105
+ />
106
+ <p class="text-xs text-gray-600">
107
+ ${activeGroup.id === "github" &&
108
+ field.key === "GITHUB_WORKSPACE_REPO"
109
+ ? githubFlow === kGithubFlowImport
110
+ ? "Your new project will live here"
111
+ : freshRepoMode === kGithubTargetRepoModeExistingEmpty
112
+ ? "Enter the owner/repo of an existing empty repository"
113
+ : "A new private repo will be created for you"
114
+ : activeGroup.id === "github" && field.key === "_GITHUB_SOURCE_REPO"
115
+ ? "The repo to import from"
116
+ : activeGroup.id === "github" && field.key === "GITHUB_TOKEN"
117
+ ? githubFlow === kGithubFlowImport
118
+ ? freshRepoMode === kGithubTargetRepoModeCreate
119
+ ? html`Use a classic PAT with${" "}<code
120
+ class="text-xs bg-black/30 px-1 rounded"
121
+ >repo</code
122
+ >${" "}scope to create the target repo. Fine-grained
123
+ works if the target already exists and can access both
124
+ repos.`
125
+ : html`Use a classic PAT with${" "}<code
126
+ class="text-xs bg-black/30 px-1 rounded"
127
+ >repo</code
128
+ >${" "}scope, or a fine-grained token with Contents +
129
+ Metadata access to both the source repo and target
130
+ repo`
131
+ : freshRepoMode === kGithubTargetRepoModeExistingEmpty
132
+ ? html`Use a classic PAT with${" "}<code
133
+ class="text-xs bg-black/30 px-1 rounded"
134
+ >repo</code
135
+ >${" "}scope, or a fine-grained token with Contents +
136
+ Metadata access to this repo`
137
+ : html`Use a classic PAT with${" "}<code
138
+ class="text-xs bg-black/30 px-1 rounded"
139
+ >repo</code
140
+ >${" "}scope to create a new private repository`
141
+ : field.hint}
142
+ </p>
143
+ </div>
144
+ `;
145
+ const fieldLookup = new Map((activeGroup.fields || []).map((field) => [field.key, field]));
146
+ const toggleChannelSection = (channelId) =>
147
+ setExpandedChannels((current) => {
148
+ const next = new Set(current);
149
+ if (next.has(channelId)) {
150
+ next.delete(channelId);
151
+ } else {
152
+ next.add(channelId);
153
+ }
154
+ return next;
155
+ });
156
+ const renderChannelAccordion = () =>
157
+ html`<div class="space-y-2">
158
+ ${kChannelAccordionDefs.map((section) => {
159
+ const isExpanded = expandedChannels.has(section.id);
160
+ const sectionFields = section.fieldKeys
161
+ .map((fieldKey) => fieldLookup.get(fieldKey))
162
+ .filter(Boolean);
163
+ const channelMeta = getChannelMeta(section.id);
164
+ const hasValue = section.fieldKeys.some((fieldKey) =>
165
+ String(vals[fieldKey] || "").trim(),
166
+ );
167
+ return html`
168
+ <div class="bg-black/20 border border-border rounded-lg overflow-hidden">
169
+ <button
170
+ type="button"
171
+ onclick=${() => toggleChannelSection(section.id)}
172
+ class="w-full flex items-center justify-between gap-2 px-3 py-2 text-left hover:bg-white/5"
173
+ >
174
+ <span class="inline-flex items-center gap-2 min-w-0">
175
+ ${channelMeta.iconSrc
176
+ ? html`<img
177
+ src=${channelMeta.iconSrc}
178
+ alt=""
179
+ class="w-4 h-4 rounded-sm"
180
+ aria-hidden="true"
181
+ />`
182
+ : null}
183
+ <span class="text-sm text-gray-200">${section.title}</span>
184
+ ${hasValue
185
+ ? html`<${Badge} tone="success">Configured</${Badge}>`
186
+ : null}
187
+ </span>
188
+ <span
189
+ class=${`ac-history-toggle shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}`}
190
+ aria-hidden="true"
191
+ >▸</span
192
+ >
193
+ </button>
194
+ ${isExpanded
195
+ ? html`
196
+ <div class="px-3 pb-3 pt-2 space-y-2 border-t border-border">
197
+ ${sectionFields.map((field) => renderStandardField(field))}
198
+ </div>
199
+ `
200
+ : null}
201
+ </div>
202
+ `;
203
+ })}
204
+ </div>`;
74
205
 
75
206
  return html`
76
207
  <div class="flex items-center justify-between">
@@ -223,73 +354,20 @@ export const WelcomeFormStep = ({
223
354
  : null}
224
355
  </div>
225
356
  `}
226
- ${(activeGroup.id === "ai"
227
- ? activeGroup.fields.filter((field) => visibleAiFieldKeys.has(field.key))
228
- : activeGroup.id === "github"
229
- ? activeGroup.fields.filter((field) =>
230
- githubFlow === kGithubFlowImport
231
- ? true
232
- : field.key !== "_GITHUB_SOURCE_REPO",
233
- )
234
- : activeGroup.fields
235
- ).map(
236
- (field) => html`
237
- <div class="space-y-1" key=${field.key}>
238
- <label class="text-xs font-medium text-gray-400"
239
- >${field.label}</label
240
- >
241
- <${SecretInput}
242
- key=${field.key}
243
- value=${vals[field.key] || ""}
244
- onInput=${(e) => setValue(field.key, e.target.value)}
245
- placeholder=${activeGroup.id === "github" &&
246
- field.key === "GITHUB_TOKEN"
247
- ? githubTokenPlaceholder
248
- : field.placeholder || ""}
249
- isSecret=${!field.isText}
250
- inputClass="flex-1 bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
251
- />
252
- <p class="text-xs text-gray-600">
253
- ${activeGroup.id === "github" &&
254
- field.key === "GITHUB_WORKSPACE_REPO"
255
- ? githubFlow === kGithubFlowImport
256
- ? "Your new project will live here"
257
- : freshRepoMode === kGithubTargetRepoModeExistingEmpty
258
- ? "Enter the owner/repo of an existing empty repository"
259
- : "A new private repo will be created for you"
260
- : activeGroup.id === "github" &&
261
- field.key === "_GITHUB_SOURCE_REPO"
262
- ? "The repo to import from"
263
- : activeGroup.id === "github" && field.key === "GITHUB_TOKEN"
264
- ? githubFlow === kGithubFlowImport
265
- ? freshRepoMode === kGithubTargetRepoModeCreate
266
- ? html`Use a classic PAT with${" "}<code
267
- class="text-xs bg-black/30 px-1 rounded"
268
- >repo</code
269
- >${" "}scope to create the target repo. Fine-grained
270
- works if the target already exists and can access both
271
- repos.`
272
- : html`Use a classic PAT with${" "}<code
273
- class="text-xs bg-black/30 px-1 rounded"
274
- >repo</code
275
- >${" "}scope, or a fine-grained token with Contents +
276
- Metadata access to both the source repo and target
277
- repo`
278
- : freshRepoMode === kGithubTargetRepoModeExistingEmpty
279
- ? html`Use a classic PAT with${" "}<code
280
- class="text-xs bg-black/30 px-1 rounded"
281
- >repo</code
282
- >${" "}scope, or a fine-grained token with Contents +
283
- Metadata access to this repo`
284
- : html`Use a classic PAT with${" "}<code
285
- class="text-xs bg-black/30 px-1 rounded"
286
- >repo</code
287
- >${" "}scope to create a new private repository`
288
- : field.hint}
289
- </p>
290
- </div>
291
- `,
292
- )}
357
+ ${activeGroup.id === "channels"
358
+ ? renderChannelAccordion()
359
+ : (activeGroup.id === "ai"
360
+ ? activeGroup.fields.filter((field) =>
361
+ visibleAiFieldKeys.has(field.key),
362
+ )
363
+ : activeGroup.id === "github"
364
+ ? activeGroup.fields.filter((field) =>
365
+ githubFlow === kGithubFlowImport
366
+ ? true
367
+ : field.key !== "_GITHUB_SOURCE_REPO",
368
+ )
369
+ : activeGroup.fields
370
+ ).map((field) => renderStandardField(field))}
293
371
  ${error
294
372
  ? html`<div
295
373
  class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm"
@@ -43,7 +43,9 @@ const PairingRow = ({ pairing, onApprove, onReject }) => {
43
43
  <div class="font-medium text-sm">
44
44
  ${pairing.code || pairing.id || "Pending request"}
45
45
  </div>
46
- <span class="text-[11px] px-2 py-0.5 rounded-full border border-border text-gray-400">
46
+ <span
47
+ class="text-[11px] px-2 py-0.5 rounded-full border border-border text-gray-400"
48
+ >
47
49
  Request
48
50
  </span>
49
51
  </div>
@@ -54,14 +56,18 @@ const PairingRow = ({ pairing, onApprove, onReject }) => {
54
56
  <button
55
57
  onclick=${handleApprove}
56
58
  disabled=${!!busyAction}
57
- class="ac-btn-green text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction ? "opacity-50 cursor-not-allowed" : ""}"
59
+ class="ac-btn-green text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction
60
+ ? "opacity-50 cursor-not-allowed"
61
+ : ""}"
58
62
  >
59
63
  ${busyAction === "approve" ? "Approving..." : "Approve"}
60
64
  </button>
61
65
  <button
62
66
  onclick=${handleReject}
63
67
  disabled=${!!busyAction}
64
- class="ac-btn-secondary text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction ? "opacity-50 cursor-not-allowed" : ""}"
68
+ class="ac-btn-secondary text-xs font-medium px-3 py-1.5 rounded-lg ${busyAction
69
+ ? "opacity-50 cursor-not-allowed"
70
+ : ""}"
65
71
  >
66
72
  ${busyAction === "reject" ? "Rejecting..." : "Reject"}
67
73
  </button>
@@ -80,17 +86,23 @@ export const WelcomePairingStep = ({
80
86
  onReject,
81
87
  canFinish,
82
88
  onContinue,
89
+ onSkip,
83
90
  }) => {
84
91
  const channelMeta = kChannelMeta[channel] || {
85
- label: channel ? channel.charAt(0).toUpperCase() + channel.slice(1) : "Channel",
92
+ label: channel
93
+ ? channel.charAt(0).toUpperCase() + channel.slice(1)
94
+ : "Channel",
86
95
  iconSrc: "",
87
96
  };
88
97
  const channelInfo = channels?.[channel];
89
98
 
90
99
  if (!channel) {
91
100
  return html`
92
- <div class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm">
93
- Missing channel configuration. Go back and add a Telegram or Discord bot token.
101
+ <div
102
+ class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm"
103
+ >
104
+ Missing channel configuration. Go back and add a Telegram or Discord bot
105
+ token.
94
106
  </div>
95
107
  `;
96
108
  }
@@ -100,12 +112,16 @@ export const WelcomePairingStep = ({
100
112
  <div class="min-h-[300px] pb-6 px-6 flex flex-col">
101
113
  <div class="flex-1 flex items-center justify-center text-center">
102
114
  <div class="space-y-3 max-w-xl mx-auto">
103
- <p class="text-sm font-medium text-green-300 mb-12">🎉 Setup complete</p>
115
+ <p class="text-sm font-medium text-green-300 mb-12">
116
+ 🎉 Setup complete
117
+ </p>
104
118
  <p class="text-xs text-gray-300">
105
- Your ${channelMeta.label} channel is connected. You can switch to ${channelMeta.label} and start using your agent now.
119
+ Your ${channelMeta.label} channel is connected. You can switch to
120
+ ${channelMeta.label} and start using your agent now.
106
121
  </p>
107
122
  <p class="text-xs text-gray-500 font-normal opacity-85">
108
- Continue to the dashboard to explore extras like Google Workspace and additional integrations.
123
+ Continue to the dashboard to explore extras like Google Workspace
124
+ and additional integrations.
109
125
  </p>
110
126
  </div>
111
127
  </div>
@@ -123,51 +139,74 @@ export const WelcomePairingStep = ({
123
139
  <div class="min-h-[300px] pb-6 flex flex-col gap-3">
124
140
  <div class="flex items-center justify-end gap-2">
125
141
  <${Badge} tone="warning"
126
- >${loading
127
- ? "Checking..."
128
- : pairings.length > 0
129
- ? "Pairing request detected"
130
- : "Awaiting pairing"}</${Badge}
142
+ >${
143
+ loading
144
+ ? "Checking..."
145
+ : pairings.length > 0
146
+ ? "Pairing request detected"
147
+ : "Awaiting pairing"
148
+ }</${Badge}
131
149
  >
132
150
  </div>
133
151
 
134
- ${pairings.length > 0
135
- ? html`<div class="flex-1 flex items-center">
136
- <div class="w-full">
137
- ${pairings.map(
138
- (pairing) =>
139
- html`<${PairingRow}
140
- key=${pairing.id}
141
- pairing=${pairing}
142
- onApprove=${onApprove}
143
- onReject=${onReject}
144
- />`,
145
- )}
146
- </div>
147
- </div>`
148
- : html`<div class="flex-1 flex items-center justify-center text-center py-4">
149
- <div class="space-y-4">
150
- ${channelMeta.iconSrc
151
- ? html`<img
152
- src=${channelMeta.iconSrc}
153
- alt=${channelMeta.label}
154
- class="w-8 h-8 mx-auto rounded-md"
155
- />`
156
- : null}
157
- <p class="text-gray-300 text-sm">
158
- Send a message to your ${channelMeta.label} bot
159
- </p>
160
- <p class="text-gray-600 text-xs">
161
- The pairing request will appear here in 5-10 seconds
162
- </p>
163
- </div>
164
- </div>`}
152
+ ${
153
+ pairings.length > 0
154
+ ? html`<div class="flex-1 flex items-center">
155
+ <div class="w-full">
156
+ ${pairings.map(
157
+ (pairing) =>
158
+ html`<${PairingRow}
159
+ key=${pairing.id}
160
+ pairing=${pairing}
161
+ onApprove=${onApprove}
162
+ onReject=${onReject}
163
+ />`,
164
+ )}
165
+ </div>
166
+ </div>`
167
+ : html`<div
168
+ class="flex-1 flex items-center justify-center text-center py-4"
169
+ >
170
+ <div class="space-y-4">
171
+ ${channelMeta.iconSrc
172
+ ? html`<img
173
+ src=${channelMeta.iconSrc}
174
+ alt=${channelMeta.label}
175
+ class="w-8 h-8 mx-auto rounded-md"
176
+ />`
177
+ : null}
178
+ <p class="text-gray-300 text-sm">
179
+ Send a message to your ${channelMeta.label} bot
180
+ </p>
181
+ <p class="text-gray-600 text-xs">
182
+ The pairing request will appear here in 5-10 seconds
183
+ </p>
184
+ </div>
185
+ </div>`
186
+ }
165
187
 
166
- ${error
167
- ? html`<div class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm">
168
- ${error}
169
- </div>`
170
- : null}
188
+ ${
189
+ error
190
+ ? html`<div
191
+ class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm"
192
+ >
193
+ ${error}
194
+ </div>`
195
+ : null
196
+ }
197
+ ${
198
+ pairings.length === 0
199
+ ? html`<div class="pt-3 text-center">
200
+ <button
201
+ type="button"
202
+ onclick=${onSkip}
203
+ class="ac-tip-link text-xs font-medium"
204
+ >
205
+ Skip pairing for now
206
+ </button>
207
+ </div>`
208
+ : null
209
+ }
171
210
  </div>
172
211
  `;
173
212
  };
@@ -62,7 +62,7 @@ export const PairingRow = ({ p, onApprove, onReject }) => {
62
62
  </div>`;
63
63
  };
64
64
 
65
- const ALL_CHANNELS = ['telegram', 'discord'];
65
+ const ALL_CHANNELS = ['telegram', 'discord', 'slack'];
66
66
 
67
67
  const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
68
68
 
@@ -73,6 +73,7 @@ export const Welcome = ({ onComplete, acVersion }) => {
73
73
  onReject=${actions.handlePairingReject}
74
74
  canFinish=${state.pairingComplete || state.canFinishPairing}
75
75
  onContinue=${actions.finishOnboarding}
76
+ onSkip=${actions.finishOnboarding}
76
77
  />`
77
78
  : html`
78
79
  <${WelcomeFormStep}
@@ -0,0 +1,23 @@
1
+ const kSingleAccountChannelProviders = new Set(["discord", "slack"]);
2
+
3
+ const hasConfiguredAccounts = ({ configuredChannelMap, provider }) => {
4
+ const channelEntry = configuredChannelMap instanceof Map
5
+ ? configuredChannelMap.get(String(provider || "").trim())
6
+ : null;
7
+ return (
8
+ Array.isArray(channelEntry?.accounts) &&
9
+ channelEntry.accounts.length > 0
10
+ );
11
+ };
12
+
13
+ export const isSingleAccountChannelProvider = (provider = "") =>
14
+ kSingleAccountChannelProviders.has(String(provider || "").trim());
15
+
16
+ export const isChannelProviderDisabledForAdd = ({
17
+ configuredChannelMap = new Map(),
18
+ provider = "",
19
+ } = {}) => {
20
+ if (!isSingleAccountChannelProvider(provider)) return false;
21
+ return hasConfiguredAccounts({ configuredChannelMap, provider });
22
+ };
23
+