thepopebot 1.2.74 → 1.2.75-beta.2

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 (63) hide show
  1. package/README.md +11 -0
  2. package/api/CLAUDE.md +2 -0
  3. package/api/index.js +95 -3
  4. package/bin/cli.js +1 -1
  5. package/bin/managed-paths.js +1 -1
  6. package/bin/sync.js +7 -2
  7. package/config/instrumentation.js +4 -0
  8. package/lib/chat/actions.js +4 -4
  9. package/lib/chat/components/chat-input.js +2 -2
  10. package/lib/chat/components/chat-input.jsx +2 -2
  11. package/lib/chat/components/settings-chat-page.js +41 -37
  12. package/lib/chat/components/settings-chat-page.jsx +39 -37
  13. package/lib/chat/components/settings-jobs-page.js +68 -10
  14. package/lib/chat/components/settings-jobs-page.jsx +99 -34
  15. package/lib/code/code-page.js +2 -1
  16. package/lib/code/code-page.jsx +2 -1
  17. package/lib/code/terminal-view.js +6 -3
  18. package/lib/code/terminal-view.jsx +6 -3
  19. package/lib/db/api-keys.js +35 -2
  20. package/lib/db/config.js +40 -4
  21. package/lib/maintenance.js +35 -0
  22. package/lib/oauth/helper.js +34 -0
  23. package/lib/tools/create-agent-job.js +3 -0
  24. package/lib/tools/docker.js +12 -5
  25. package/package.json +3 -2
  26. package/templates/docker-compose.custom.yml +1 -0
  27. package/templates/docker-compose.litellm.yml +1 -0
  28. package/templates/docker-compose.yml +2 -0
  29. package/templates/skills/agent-job-secrets/SKILL.md +25 -0
  30. package/templates/skills/agent-job-secrets/agent-job-secrets.js +66 -0
  31. package/templates/skills/playwright-cli/SKILL.md +294 -0
  32. package/templates/skills/brave-search/SKILL.md +0 -79
  33. package/templates/skills/brave-search/content.js +0 -86
  34. package/templates/skills/brave-search/package-lock.json +0 -621
  35. package/templates/skills/brave-search/package.json +0 -14
  36. package/templates/skills/brave-search/search.js +0 -199
  37. package/templates/skills/browser-tools/SKILL.md +0 -196
  38. package/templates/skills/browser-tools/browser-content.js +0 -103
  39. package/templates/skills/browser-tools/browser-cookies.js +0 -35
  40. package/templates/skills/browser-tools/browser-eval.js +0 -53
  41. package/templates/skills/browser-tools/browser-hn-scraper.js +0 -108
  42. package/templates/skills/browser-tools/browser-nav.js +0 -44
  43. package/templates/skills/browser-tools/browser-pick.js +0 -162
  44. package/templates/skills/browser-tools/browser-screenshot.js +0 -34
  45. package/templates/skills/browser-tools/browser-start.js +0 -87
  46. package/templates/skills/browser-tools/package-lock.json +0 -2556
  47. package/templates/skills/browser-tools/package.json +0 -19
  48. package/templates/skills/get-secret/SKILL.md +0 -34
  49. package/templates/skills/get-secret/get-secret.js +0 -33
  50. package/templates/skills/google-docs/SKILL.md +0 -23
  51. package/templates/skills/google-docs/create.sh +0 -69
  52. package/templates/skills/google-drive/SKILL.md +0 -47
  53. package/templates/skills/google-drive/delete.sh +0 -47
  54. package/templates/skills/google-drive/download.sh +0 -50
  55. package/templates/skills/google-drive/list.sh +0 -41
  56. package/templates/skills/google-drive/upload.sh +0 -76
  57. package/templates/skills/kie-ai/SKILL.md +0 -38
  58. package/templates/skills/kie-ai/generate-image.sh +0 -77
  59. package/templates/skills/kie-ai/generate-video.sh +0 -69
  60. package/templates/skills/youtube-transcript/SKILL.md +0 -41
  61. package/templates/skills/youtube-transcript/package-lock.json +0 -24
  62. package/templates/skills/youtube-transcript/package.json +0 -8
  63. package/templates/skills/youtube-transcript/transcript.js +0 -84
package/README.md CHANGED
@@ -192,6 +192,17 @@ See [Different Models](docs/RUNNING_DIFFERENT_MODELS.md) for the full provider r
192
192
 
193
193
  ---
194
194
 
195
+ ## Known Issues
196
+
197
+ ### Windows: `SQLITE_IOERR_SHMOPEN`
198
+
199
+ SQLite can't create or open its shared-memory (`.shm`) file. Common causes:
200
+
201
+ - **Antivirus** (Windows Defender, etc.) locking the database files — add your project folder to the exclusion list
202
+ - **Cloud-synced folders** (OneDrive, Dropbox, Google Drive) — move your project to a non-synced directory like `C:\Projects\`
203
+
204
+ ---
205
+
195
206
  ## Docs
196
207
 
197
208
  | Document | Description |
package/api/CLAUDE.md CHANGED
@@ -25,6 +25,8 @@ Browser-facing data fetching uses **fetch route handlers** colocated with pages
25
25
  |--------|------|------|---------|
26
26
  | GET | `/api/ping` | None | Health check |
27
27
  | POST | `/api/create-agent-job` | `x-api-key` | Create agent job |
28
+ | GET | `/api/get-agent-job-secret` | `x-api-key` | Get an agent job secret; oauth2 credentials return only the access_token (auto-refreshed) |
29
+ | POST | `/api/set-agent-job-secret` | `x-api-key` | Set/update an agent job secret (for agents to persist rotated credentials) |
28
30
  | GET | `/api/agent-jobs/status` | `x-api-key` | Agent job status (query: `?agent_job_id=`) |
29
31
  | POST | `/api/telegram/webhook` | Telegram webhook secret | Telegram message handler |
30
32
  | POST | `/api/telegram/register` | `x-api-key` | Register bot token + webhook URL |
package/api/index.js CHANGED
@@ -11,6 +11,9 @@ import { getConfig } from '../lib/config.js';
11
11
  import { parseOAuthState, exchangeCodeForToken } from '../lib/oauth/helper.js';
12
12
  import { setAgentJobSecret } from '../lib/db/config.js';
13
13
 
14
+ // ── Per-key lock for OAuth token refresh ────────────────────────────
15
+ const _refreshLocks = new Map();
16
+
14
17
  // Bot token — resolved from DB/env, can be overridden by /telegram/register
15
18
  let telegramBotToken = null;
16
19
 
@@ -104,6 +107,81 @@ async function handleCreateAgentJob(request) {
104
107
  }
105
108
  }
106
109
 
110
+ async function handleGetAgentSecret(request) {
111
+ const record = verifyApiKey(request.headers.get('x-api-key'));
112
+ if (record.type !== 'agent_job_api_key') {
113
+ return Response.json({ error: 'Forbidden' }, { status: 403 });
114
+ }
115
+
116
+ const key = new URL(request.url).searchParams.get('key');
117
+ if (!key) return Response.json({ error: 'Missing key' }, { status: 400 });
118
+
119
+ const { getAgentJobSecretRaw, setAgentJobSecret: saveSecret } = await import('../lib/db/config.js');
120
+ const raw = getAgentJobSecretRaw(key);
121
+ if (!raw) return Response.json({ error: 'Not found' }, { status: 404 });
122
+
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(raw);
126
+ } catch {
127
+ // Plain string
128
+ return Response.json({ value: raw });
129
+ }
130
+
131
+ if (parsed.type === 'oauth2') {
132
+ // Serialize refresh per key — prevents concurrent requests from racing on token rotation
133
+ if (!_refreshLocks.has(key)) _refreshLocks.set(key, Promise.resolve());
134
+ let release;
135
+ const gate = new Promise((r) => { release = r; });
136
+ const prev = _refreshLocks.get(key);
137
+ _refreshLocks.set(key, gate);
138
+ await prev;
139
+
140
+ try {
141
+ // Re-read after acquiring lock — previous request may have already refreshed
142
+ const freshRaw = getAgentJobSecretRaw(key);
143
+ const freshParsed = freshRaw ? JSON.parse(freshRaw) : parsed;
144
+
145
+ const { refreshOAuthToken } = await import('../lib/oauth/helper.js');
146
+ const newToken = await refreshOAuthToken({
147
+ refreshToken: freshParsed.token.refresh_token,
148
+ clientId: freshParsed.clientId,
149
+ clientSecret: freshParsed.clientSecret,
150
+ tokenUrl: freshParsed.tokenUrl,
151
+ });
152
+ // Persist updated token (refresh token may have rotated)
153
+ saveSecret(key, JSON.stringify({ ...freshParsed, token: { ...freshParsed.token, ...newToken } }), 'refresh');
154
+ return Response.json({ value: newToken.access_token });
155
+ } catch (err) {
156
+ console.error(`[secrets] OAuth refresh failed for "${key}":`, err.message);
157
+ return Response.json({ error: `OAuth refresh failed: ${err.message}` }, { status: 502 });
158
+ } finally {
159
+ release();
160
+ }
161
+ }
162
+ if (parsed.type === 'oauth_token') {
163
+ return Response.json({ value: JSON.stringify(parsed.token) });
164
+ }
165
+ // Unknown structured value — return raw
166
+ return Response.json({ value: raw });
167
+ }
168
+
169
+ async function handleSetAgentSecret(request) {
170
+ const record = verifyApiKey(request.headers.get('x-api-key'));
171
+ if (record.type !== 'agent_job_api_key') {
172
+ return Response.json({ error: 'Forbidden' }, { status: 403 });
173
+ }
174
+
175
+ const body = await request.json();
176
+ const { key, value } = body;
177
+ if (!key || typeof value !== 'string') {
178
+ return Response.json({ error: 'Missing key or value' }, { status: 400 });
179
+ }
180
+ const { setAgentJobSecret } = await import('../lib/db/config.js');
181
+ setAgentJobSecret(key, value, 'agent');
182
+ return Response.json({ success: true });
183
+ }
184
+
107
185
  async function handleTelegramRegister(request) {
108
186
  const body = await request.json();
109
187
  const { bot_token, webhook_url } = body;
@@ -248,9 +326,21 @@ async function handleOAuthCallback(request) {
248
326
  redirectUri,
249
327
  });
250
328
 
251
- // Save the full token JSON as the secret value
252
- const tokenJson = JSON.stringify(tokenData);
253
- setAgentJobSecret(state.secretName, tokenJson, 'oauth');
329
+ // Save token with typed wrapper so the API can auto-refresh on fetch
330
+ const secretType = state.secretType || 'oauth2';
331
+ let stored;
332
+ if (secretType === 'oauth_token') {
333
+ stored = JSON.stringify({ type: 'oauth_token', token: tokenData });
334
+ } else {
335
+ stored = JSON.stringify({
336
+ type: 'oauth2',
337
+ token: tokenData,
338
+ clientId: state.clientId,
339
+ clientSecret: state.clientSecret,
340
+ tokenUrl: state.tokenUrl,
341
+ });
342
+ }
343
+ setAgentJobSecret(state.secretName, stored, 'oauth');
254
344
 
255
345
  return oauthResultPage(true, state.secretName);
256
346
  } catch (err) {
@@ -315,6 +405,7 @@ async function POST(request) {
315
405
  // Route to handler
316
406
  switch (routePath) {
317
407
  case '/create-agent-job': return handleCreateAgentJob(request);
408
+ case '/set-agent-job-secret': return handleSetAgentSecret(request);
318
409
  case '/telegram/webhook': return handleTelegramWebhook(request);
319
410
  case '/telegram/register': return handleTelegramRegister(request);
320
411
  case '/github/webhook': return handleGithubWebhook(request);
@@ -333,6 +424,7 @@ async function GET(request) {
333
424
  switch (routePath) {
334
425
  case '/ping': return Response.json({ message: 'Pong!' });
335
426
  case '/agent-jobs/status': return handleAgentJobStatus(request);
427
+ case '/get-agent-job-secret': return handleGetAgentSecret(request);
336
428
  case '/oauth/callback': return handleOAuthCallback(request);
337
429
  default: return Response.json({ error: 'Not found' }, { status: 404 });
338
430
  }
package/bin/cli.js CHANGED
@@ -262,7 +262,7 @@ async function init() {
262
262
  }
263
263
 
264
264
  // Create default skill activation symlinks
265
- const defaultSkills = ['get-secret'];
265
+ const defaultSkills = ['agent-job-secrets'];
266
266
  const activeDir = path.join(cwd, 'skills', 'active');
267
267
  fs.mkdirSync(activeDir, { recursive: true });
268
268
  for (const skill of defaultSkills) {
@@ -13,7 +13,7 @@ export const MANAGED_PATHS = [
13
13
  'skills/CLAUDE.md',
14
14
  'cron/CLAUDE.md',
15
15
  'triggers/CLAUDE.md',
16
- 'docs/CLAUDE.md',
16
+ 'docs/',
17
17
  ];
18
18
 
19
19
  export function isManaged(relPath) {
package/bin/sync.js CHANGED
@@ -277,8 +277,7 @@ function buildDockerImage(projectPath) {
277
277
  fs.cpSync(webSrc, webDest, { recursive: true });
278
278
 
279
279
  try {
280
- // Build using stdin Dockerfile with project dir as context (no cache to ensure fresh package)
281
- execSync(`docker build --no-cache -f - -t ${imageTag} .`, {
280
+ execSync(`docker build -f - -t ${imageTag} .`, {
282
281
  input: dockerfile,
283
282
  stdio: ['pipe', 'inherit', 'inherit'],
284
283
  cwd: projectPath,
@@ -287,6 +286,12 @@ function buildDockerImage(projectPath) {
287
286
  fs.rmSync(webDest, { recursive: true, force: true });
288
287
  }
289
288
 
289
+ // Clean up dangling images from previous builds
290
+ try {
291
+ execSync('docker image prune -f', { stdio: 'ignore' });
292
+ } catch {}
293
+
294
+
290
295
  // Update THEPOPEBOT_VERSION in .env
291
296
  const envPath = path.join(projectPath, '.env');
292
297
  if (fs.existsSync(envPath)) {
@@ -70,5 +70,9 @@ export async function register() {
70
70
  const { startClusterRuntime } = await import('../lib/cluster/runtime.js');
71
71
  startClusterRuntime();
72
72
 
73
+ // Start internal maintenance cron (cleanup expired agent job keys, etc.)
74
+ const { startMaintenanceCron } = await import('../lib/maintenance.js');
75
+ startMaintenanceCron();
76
+
73
77
  console.log('thepopebot initialized');
74
78
  }
@@ -915,10 +915,10 @@ export async function getChatSettings() {
915
915
  const customProviders = getCustomProviders();
916
916
 
917
917
  // Get active config values
918
- const activeProvider = getConfigValue('LLM_PROVIDER') || process.env.LLM_PROVIDER || 'anthropic';
919
- const activeModel = getConfigValue('LLM_MODEL') || process.env.LLM_MODEL || '';
920
- const maxTokens = getConfigValue('LLM_MAX_TOKENS') || process.env.LLM_MAX_TOKENS || '4096';
921
- const agentBackend = getConfigValue('AGENT_BACKEND') || process.env.AGENT_BACKEND || 'claude-code';
918
+ const activeProvider = getConfigValue('LLM_PROVIDER') || '';
919
+ const activeModel = getConfigValue('LLM_MODEL') || '';
920
+ const maxTokens = getConfigValue('LLM_MAX_TOKENS') || '4096';
921
+ const agentBackend = getConfigValue('AGENT_BACKEND') || '';
922
922
 
923
923
  return {
924
924
  builtinProviders: BUILTIN_PROVIDERS,
@@ -87,7 +87,7 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles, d
87
87
  const textarea = textareaRef.current;
88
88
  if (!textarea) return;
89
89
  textarea.style.height = "auto";
90
- textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
90
+ textarea.style.height = `${textarea.scrollHeight}px`;
91
91
  }, []);
92
92
  useEffect(() => {
93
93
  adjustHeight();
@@ -213,7 +213,7 @@ function ChatInput({ input, setInput, onSubmit, status, stop, files, setFiles, d
213
213
  className: cn(
214
214
  "w-full resize-none bg-transparent px-2 py-1.5 text-sm text-foreground",
215
215
  "placeholder:text-muted-foreground focus:outline-none",
216
- "max-h-[200px]"
216
+ "min-h-[84px] md:min-h-0 max-h-[40vh] md:max-h-[200px]"
217
217
  ),
218
218
  disabled: isStreaming
219
219
  }
@@ -69,7 +69,7 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
69
69
  const textarea = textareaRef.current;
70
70
  if (!textarea) return;
71
71
  textarea.style.height = 'auto';
72
- textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
72
+ textarea.style.height = `${textarea.scrollHeight}px`;
73
73
  }, []);
74
74
 
75
75
  useEffect(() => {
@@ -229,7 +229,7 @@ export function ChatInput({ input, setInput, onSubmit, status, stop, files, setF
229
229
  className={cn(
230
230
  'w-full resize-none bg-transparent px-2 py-1.5 text-sm text-foreground',
231
231
  'placeholder:text-muted-foreground focus:outline-none',
232
- 'max-h-[200px]'
232
+ 'min-h-[84px] md:min-h-0 max-h-[40vh] md:max-h-[200px]'
233
233
  )}
234
234
  disabled={isStreaming}
235
235
  />
@@ -57,11 +57,30 @@ function ActiveConfig({ settings, onSave }) {
57
57
  const [saved, setSaved] = useState(false);
58
58
  const initialized = useRef(false);
59
59
  const saveTimer = useRef(null);
60
+ const availableProviders = [];
61
+ if (settings?.builtinProviders && settings?.credentialStatuses) {
62
+ const statusMap = new Map(settings.credentialStatuses.map((s) => [s.key, s.isSet]));
63
+ for (const [slug, prov] of Object.entries(settings.builtinProviders)) {
64
+ const hasKey = prov.credentials.some((c) => statusMap.get(c.key));
65
+ if (hasKey) {
66
+ availableProviders.push({ slug, name: prov.name, models: prov.models });
67
+ }
68
+ }
69
+ }
70
+ if (settings?.customProviders) {
71
+ for (const cp of settings.customProviders) {
72
+ availableProviders.push({ slug: cp.key, name: cp.name, models: cp.models.map((m) => ({ id: m, name: m })) });
73
+ }
74
+ }
60
75
  useEffect(() => {
61
76
  if (settings?.active) {
62
- setProvider(settings.active.provider || "anthropic");
63
- setModel(settings.active.model || "");
64
- setModelText(settings.active.model || "");
77
+ const prov = settings.active.provider || availableProviders[0]?.slug || "";
78
+ const resolved = availableProviders.find((p) => p.slug === prov);
79
+ const models = resolved?.models || [];
80
+ const def = models.find((m) => m.default);
81
+ setProvider(prov);
82
+ setModel(settings.active.model || def?.id || models[0]?.id || "");
83
+ setModelText(settings.active.model || def?.id || models[0]?.id || "");
65
84
  setMaxTokens(settings.active.maxTokens || "4096");
66
85
  setTimeout(() => {
67
86
  initialized.current = true;
@@ -82,21 +101,6 @@ function ActiveConfig({ settings, onSave }) {
82
101
  if (saveTimer.current) clearTimeout(saveTimer.current);
83
102
  saveTimer.current = setTimeout(() => doSave(p, m, mt, ws), 800);
84
103
  }, [doSave]);
85
- const availableProviders = [];
86
- if (settings?.builtinProviders && settings?.credentialStatuses) {
87
- const statusMap = new Map(settings.credentialStatuses.map((s) => [s.key, s.isSet]));
88
- for (const [slug, prov] of Object.entries(settings.builtinProviders)) {
89
- const hasKey = prov.credentials.some((c) => statusMap.get(c.key));
90
- if (hasKey) {
91
- availableProviders.push({ slug, name: prov.name, models: prov.models });
92
- }
93
- }
94
- }
95
- if (settings?.customProviders) {
96
- for (const cp of settings.customProviders) {
97
- availableProviders.push({ slug: cp.key, name: cp.name, models: cp.models.map((m) => ({ id: m, name: m })) });
98
- }
99
- }
100
104
  const selectedProvider = availableProviders.find((p) => p.slug === provider);
101
105
  const handleProviderChange = (slug) => {
102
106
  setProvider(slug);
@@ -129,28 +133,28 @@ function ActiveConfig({ settings, onSave }) {
129
133
  }
130
134
  };
131
135
  return /* @__PURE__ */ jsxs("div", { className: "rounded-lg border bg-card p-4", children: [
132
- (saving || saved) && /* @__PURE__ */ jsxs("div", { className: "flex justify-end mb-2", children: [
133
- saving && /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: "Saving..." }),
134
- saved && /* @__PURE__ */ jsxs("span", { className: "text-xs text-green-500 inline-flex items-center gap-1", children: [
135
- /* @__PURE__ */ jsx(CheckIcon, { size: 12 }),
136
- " Saved"
137
- ] })
138
- ] }),
139
136
  /* @__PURE__ */ jsxs("div", { className: "divide-y divide-border", children: [
140
137
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between py-3 first:pt-0", children: [
141
138
  /* @__PURE__ */ jsx("label", { className: "text-sm font-medium shrink-0", children: "Provider" }),
142
- /* @__PURE__ */ jsxs(
143
- "select",
144
- {
145
- value: provider,
146
- onChange: (e) => handleProviderChange(e.target.value),
147
- className: "w-48 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-foreground",
148
- children: [
149
- availableProviders.map((p) => /* @__PURE__ */ jsx("option", { value: p.slug, children: p.name }, p.slug)),
150
- availableProviders.length === 0 && /* @__PURE__ */ jsx("option", { value: "", disabled: true, children: "No providers configured" })
151
- ]
152
- }
153
- )
139
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
140
+ saving && /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: "Saving..." }),
141
+ saved && /* @__PURE__ */ jsxs("span", { className: "text-xs text-green-500 inline-flex items-center gap-1", children: [
142
+ /* @__PURE__ */ jsx(CheckIcon, { size: 12 }),
143
+ " Saved"
144
+ ] }),
145
+ /* @__PURE__ */ jsxs(
146
+ "select",
147
+ {
148
+ value: provider,
149
+ onChange: (e) => handleProviderChange(e.target.value),
150
+ className: "w-48 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-foreground",
151
+ children: [
152
+ availableProviders.map((p) => /* @__PURE__ */ jsx("option", { value: p.slug, children: p.name }, p.slug)),
153
+ availableProviders.length === 0 && /* @__PURE__ */ jsx("option", { value: "", disabled: true, children: "No providers configured" })
154
+ ]
155
+ }
156
+ )
157
+ ] })
154
158
  ] }),
155
159
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between py-3", children: [
156
160
  /* @__PURE__ */ jsx("label", { className: "text-sm font-medium shrink-0", children: "Model" }),
@@ -73,11 +73,31 @@ function ActiveConfig({ settings, onSave }) {
73
73
  const initialized = useRef(false);
74
74
  const saveTimer = useRef(null);
75
75
 
76
+ const availableProviders = [];
77
+ if (settings?.builtinProviders && settings?.credentialStatuses) {
78
+ const statusMap = new Map(settings.credentialStatuses.map((s) => [s.key, s.isSet]));
79
+ for (const [slug, prov] of Object.entries(settings.builtinProviders)) {
80
+ const hasKey = prov.credentials.some((c) => statusMap.get(c.key));
81
+ if (hasKey) {
82
+ availableProviders.push({ slug, name: prov.name, models: prov.models });
83
+ }
84
+ }
85
+ }
86
+ if (settings?.customProviders) {
87
+ for (const cp of settings.customProviders) {
88
+ availableProviders.push({ slug: cp.key, name: cp.name, models: cp.models.map((m) => ({ id: m, name: m })) });
89
+ }
90
+ }
91
+
76
92
  useEffect(() => {
77
93
  if (settings?.active) {
78
- setProvider(settings.active.provider || 'anthropic');
79
- setModel(settings.active.model || '');
80
- setModelText(settings.active.model || '');
94
+ const prov = settings.active.provider || availableProviders[0]?.slug || '';
95
+ const resolved = availableProviders.find((p) => p.slug === prov);
96
+ const models = resolved?.models || [];
97
+ const def = models.find((m) => m.default);
98
+ setProvider(prov);
99
+ setModel(settings.active.model || def?.id || models[0]?.id || '');
100
+ setModelText(settings.active.model || def?.id || models[0]?.id || '');
81
101
  setMaxTokens(settings.active.maxTokens || '4096');
82
102
  setTimeout(() => { initialized.current = true; }, 100);
83
103
  }
@@ -99,22 +119,6 @@ function ActiveConfig({ settings, onSave }) {
99
119
  saveTimer.current = setTimeout(() => doSave(p, m, mt, ws), 800);
100
120
  }, [doSave]);
101
121
 
102
- const availableProviders = [];
103
- if (settings?.builtinProviders && settings?.credentialStatuses) {
104
- const statusMap = new Map(settings.credentialStatuses.map((s) => [s.key, s.isSet]));
105
- for (const [slug, prov] of Object.entries(settings.builtinProviders)) {
106
- const hasKey = prov.credentials.some((c) => statusMap.get(c.key));
107
- if (hasKey) {
108
- availableProviders.push({ slug, name: prov.name, models: prov.models });
109
- }
110
- }
111
- }
112
- if (settings?.customProviders) {
113
- for (const cp of settings.customProviders) {
114
- availableProviders.push({ slug: cp.key, name: cp.name, models: cp.models.map((m) => ({ id: m, name: m })) });
115
- }
116
- }
117
-
118
122
  const selectedProvider = availableProviders.find((p) => p.slug === provider);
119
123
 
120
124
  const handleProviderChange = (slug) => {
@@ -156,27 +160,25 @@ function ActiveConfig({ settings, onSave }) {
156
160
 
157
161
  return (
158
162
  <div className="rounded-lg border bg-card p-4">
159
- {(saving || saved) && (
160
- <div className="flex justify-end mb-2">
161
- {saving && <span className="text-xs text-muted-foreground">Saving...</span>}
162
- {saved && <span className="text-xs text-green-500 inline-flex items-center gap-1"><CheckIcon size={12} /> Saved</span>}
163
- </div>
164
- )}
165
163
  <div className="divide-y divide-border">
166
164
  <div className="flex items-center justify-between py-3 first:pt-0">
167
165
  <label className="text-sm font-medium shrink-0">Provider</label>
168
- <select
169
- value={provider}
170
- onChange={(e) => handleProviderChange(e.target.value)}
171
- className="w-48 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-foreground"
172
- >
173
- {availableProviders.map((p) => (
174
- <option key={p.slug} value={p.slug}>{p.name}</option>
175
- ))}
176
- {availableProviders.length === 0 && (
177
- <option value="" disabled>No providers configured</option>
178
- )}
179
- </select>
166
+ <div className="flex items-center gap-2">
167
+ {saving && <span className="text-xs text-muted-foreground">Saving...</span>}
168
+ {saved && <span className="text-xs text-green-500 inline-flex items-center gap-1"><CheckIcon size={12} /> Saved</span>}
169
+ <select
170
+ value={provider}
171
+ onChange={(e) => handleProviderChange(e.target.value)}
172
+ className="w-48 rounded-md border border-border bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-foreground"
173
+ >
174
+ {availableProviders.map((p) => (
175
+ <option key={p.slug} value={p.slug}>{p.name}</option>
176
+ ))}
177
+ {availableProviders.length === 0 && (
178
+ <option value="" disabled>No providers configured</option>
179
+ )}
180
+ </select>
181
+ </div>
180
182
  </div>
181
183
 
182
184
  <div className="flex items-center justify-between py-3">
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useState, useEffect, useRef, useCallback } from "react";
4
- import { PlusIcon, CopyIcon, CheckIcon, SpinnerIcon } from "./icons.js";
5
- import { SecretRow, Dialog, EmptyState } from "./settings-shared.js";
4
+ import { PlusIcon, CopyIcon, CheckIcon, SpinnerIcon, TrashIcon } from "./icons.js";
5
+ import { SecretRow, StatusBadge, Dialog, EmptyState } from "./settings-shared.js";
6
6
  import { OAUTH_PROVIDERS } from "../../oauth/providers.js";
7
7
  import {
8
8
  getAgentJobSecrets,
@@ -137,7 +137,7 @@ function ProviderCombobox({ value, onChange, inputClass }) {
137
137
  open && filtered.length === 0 && query && /* @__PURE__ */ jsx("div", { className: "absolute z-10 mt-1 w-full rounded-md border border-border bg-background shadow-lg px-3 py-2 text-sm text-muted-foreground", children: "No providers found" })
138
138
  ] });
139
139
  }
140
- function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
140
+ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess, editingSecret }) {
141
141
  const [mode, setMode] = useState("manual");
142
142
  const [name, setName] = useState("");
143
143
  const [error, setError] = useState(null);
@@ -156,8 +156,8 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
156
156
  const timeoutRef = useRef(null);
157
157
  useEffect(() => {
158
158
  if (open) {
159
- setMode("manual");
160
- setName("");
159
+ setMode(editingSecret ? "oauth" : "manual");
160
+ setName(editingSecret?.key || "");
161
161
  setError(null);
162
162
  setValue("");
163
163
  setShowValue(false);
@@ -169,7 +169,7 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
169
169
  setStatus("form");
170
170
  setCopied(false);
171
171
  setRedirectUri(`${window.location.origin}/api/oauth/callback`);
172
- setTimeout(() => nameRef.current?.focus(), 50);
172
+ if (!editingSecret) setTimeout(() => nameRef.current?.focus(), 50);
173
173
  }
174
174
  return () => {
175
175
  if (timeoutRef.current) clearTimeout(timeoutRef.current);
@@ -262,7 +262,7 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
262
262
  }, 5 * 60 * 1e3);
263
263
  };
264
264
  const inputClass = "w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-foreground";
265
- return /* @__PURE__ */ jsx(Dialog, { open, onClose: onCancel, title: "Add Secret", children: status === "success" ? /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-center gap-2 py-8 text-green-500", children: [
265
+ return /* @__PURE__ */ jsx(Dialog, { open, onClose: onCancel, title: editingSecret ? "Re-authorize Secret" : "Add Secret", children: status === "success" ? /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-center gap-2 py-8 text-green-500", children: [
266
266
  /* @__PURE__ */ jsx(CheckIcon, { size: 20 }),
267
267
  /* @__PURE__ */ jsx("span", { className: "text-sm font-medium", children: "Token saved!" })
268
268
  ] }) : status === "waiting" ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center gap-3 py-8", children: [
@@ -271,7 +271,7 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
271
271
  /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "Complete the login in the popup window." })
272
272
  ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
273
273
  /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
274
- /* @__PURE__ */ jsxs("div", { className: "flex rounded-md border border-border overflow-hidden", children: [
274
+ !editingSecret && /* @__PURE__ */ jsxs("div", { className: "flex rounded-md border border-border overflow-hidden", children: [
275
275
  /* @__PURE__ */ jsx(
276
276
  "button",
277
277
  {
@@ -301,7 +301,8 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
301
301
  value: name,
302
302
  onChange: (e) => setName(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, "")),
303
303
  placeholder: mode === "manual" ? "e.g. GOOGLE_SERVICE_ACCOUNT_KEY" : "e.g. GOOGLE_OAUTH_TOKEN",
304
- className: inputClass,
304
+ className: `${inputClass}${editingSecret ? " text-muted-foreground bg-muted" : ""}`,
305
+ readOnly: !!editingSecret,
305
306
  onKeyDown: (e) => e.key === "Enter" && mode === "manual" && handleSave()
306
307
  }
307
308
  )
@@ -439,10 +440,49 @@ function AddSecretDialog({ open, onAdd, onCancel, onOAuthSuccess }) {
439
440
  ] })
440
441
  ] }) });
441
442
  }
443
+ function OAuthSecretRow({ secret, onReauthorize, onDelete }) {
444
+ const [confirmDelete, setConfirmDelete] = useState(false);
445
+ const handleDelete = async () => {
446
+ if (!confirmDelete) {
447
+ setConfirmDelete(true);
448
+ setTimeout(() => setConfirmDelete(false), 3e3);
449
+ return;
450
+ }
451
+ await onDelete();
452
+ setConfirmDelete(false);
453
+ };
454
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between py-3", children: [
455
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [
456
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium font-mono", children: secret.key }),
457
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: "OAuth" }),
458
+ /* @__PURE__ */ jsx(StatusBadge, { isSet: secret.isSet })
459
+ ] }),
460
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5 shrink-0 self-start sm:self-auto", children: [
461
+ /* @__PURE__ */ jsx(
462
+ "button",
463
+ {
464
+ onClick: onReauthorize,
465
+ className: "rounded-md px-2.5 py-1.5 text-xs font-medium border border-border text-muted-foreground hover:bg-accent hover:text-foreground transition-colors",
466
+ children: "Re-authorize"
467
+ }
468
+ ),
469
+ /* @__PURE__ */ jsx(
470
+ "button",
471
+ {
472
+ onClick: handleDelete,
473
+ className: `rounded-md p-1.5 text-xs border transition-colors ${confirmDelete ? "border-destructive text-destructive hover:bg-destructive/10" : "border-border text-muted-foreground hover:text-destructive hover:border-destructive"}`,
474
+ title: confirmDelete ? "Click again to confirm" : "Delete",
475
+ children: /* @__PURE__ */ jsx(TrashIcon, { size: 12 })
476
+ }
477
+ )
478
+ ] })
479
+ ] });
480
+ }
442
481
  function JobsPage() {
443
482
  const [secrets, setSecrets] = useState([]);
444
483
  const [loading, setLoading] = useState(true);
445
484
  const [showAdd, setShowAdd] = useState(false);
485
+ const [reauthorizing, setReauthorizing] = useState(null);
446
486
  const loadSecrets = async () => {
447
487
  try {
448
488
  const result = await getAgentJobSecrets();
@@ -501,6 +541,16 @@ function JobsPage() {
501
541
  onOAuthSuccess: loadSecrets
502
542
  }
503
543
  ),
544
+ /* @__PURE__ */ jsx(
545
+ AddSecretDialog,
546
+ {
547
+ open: !!reauthorizing,
548
+ onAdd: handleAdd,
549
+ onCancel: () => setReauthorizing(null),
550
+ onOAuthSuccess: loadSecrets,
551
+ editingSecret: reauthorizing
552
+ }
553
+ ),
504
554
  secrets.length === 0 ? /* @__PURE__ */ jsx(
505
555
  EmptyState,
506
556
  {
@@ -508,7 +558,15 @@ function JobsPage() {
508
558
  actionLabel: "Add secret",
509
559
  onAction: () => setShowAdd(true)
510
560
  }
511
- ) : /* @__PURE__ */ jsx("div", { className: "rounded-lg border bg-card p-4", children: /* @__PURE__ */ jsx("div", { className: "divide-y divide-border", children: secrets.map((s) => /* @__PURE__ */ jsx(
561
+ ) : /* @__PURE__ */ jsx("div", { className: "rounded-lg border bg-card p-4", children: /* @__PURE__ */ jsx("div", { className: "divide-y divide-border", children: secrets.map((s) => s.secretType === "oauth2" ? /* @__PURE__ */ jsx(
562
+ OAuthSecretRow,
563
+ {
564
+ secret: s,
565
+ onReauthorize: () => setReauthorizing(s),
566
+ onDelete: () => handleDelete(s.key)
567
+ },
568
+ s.key
569
+ ) : /* @__PURE__ */ jsx(
512
570
  SecretRow,
513
571
  {
514
572
  label: s.key,