bloby-bot 0.60.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist-bloby/assets/{bloby-8GjzRxjC.js → bloby-DO7g-v11.js} +4 -4
  2. package/dist-bloby/assets/globals-CF0bs396.css +2 -0
  3. package/dist-bloby/assets/{globals-D-b6XZqk.js → globals-CwR3dDCz.js} +2 -2
  4. package/dist-bloby/assets/{highlighted-body-OFNGDK62-DrKKm93B.js → highlighted-body-OFNGDK62-C2Wmb17B.js} +1 -1
  5. package/dist-bloby/assets/mermaid-GHXKKRXX-CILe07ZG.js +1 -0
  6. package/dist-bloby/assets/{onboard-DJNuzfZA.js → onboard-DcGLkITd.js} +1 -1
  7. package/dist-bloby/bloby.html +3 -3
  8. package/dist-bloby/onboard.html +3 -3
  9. package/package.json +4 -3
  10. package/shared/config.ts +25 -0
  11. package/supervisor/channels/manager.ts +112 -12
  12. package/supervisor/channels/telegram.ts +361 -0
  13. package/supervisor/channels/types.ts +5 -1
  14. package/supervisor/channels/whatsapp.ts +4 -5
  15. package/supervisor/chat/OnboardWizard.tsx +163 -110
  16. package/supervisor/harnesses/claude.ts +7 -0
  17. package/supervisor/harnesses/pi/index.ts +1 -1
  18. package/supervisor/harnesses/pi/tools/path-safety.ts +8 -1
  19. package/supervisor/index.ts +334 -7
  20. package/supervisor/workspace-guard.js +3 -3
  21. package/worker/prompts/bloby-system-prompt-codex.txt +2 -2
  22. package/worker/prompts/bloby-system-prompt-pi.txt +2 -2
  23. package/worker/prompts/bloby-system-prompt.txt +2 -2
  24. package/workspace/skills/telegram/.claude-plugin/plugin.json +6 -0
  25. package/workspace/skills/telegram/SKILL.md +230 -0
  26. package/workspace/skills/telegram/skill.json +15 -0
  27. package/dist-bloby/assets/globals-eJ7lScsq.css +0 -2
  28. package/dist-bloby/assets/mermaid-GHXKKRXX-CxqocSKs.js +0 -1
@@ -518,11 +518,10 @@ export class WhatsAppChannel implements ChannelProvider {
518
518
  }
519
519
  }
520
520
 
521
- // Skip if no text AND no images
522
- if (!rawText && images.length === 0) continue;
523
-
524
- // Use a default text for image-only messages
525
- if (!rawText && images.length > 0) {
521
+ // Skip if no text AND no images; otherwise default text for image-only
522
+ // messages. Collapsing both branches also narrows `rawText` to `string`.
523
+ if (!rawText) {
524
+ if (images.length === 0) continue;
526
525
  rawText = '(image)';
527
526
  }
528
527
 
@@ -60,6 +60,8 @@ const PROVIDERS = [
60
60
 
61
61
  const MODELS: Record<string, { id: string; label: string }[]> = {
62
62
  anthropic: [
63
+ { id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M context)' },
64
+ { id: 'claude-opus-4-8', label: 'Opus 4.8' },
63
65
  { id: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M context)' },
64
66
  { id: 'claude-opus-4-7', label: 'Opus 4.7' },
65
67
  { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6 (1M context)' },
@@ -87,7 +89,7 @@ const HANDLES = [
87
89
 
88
90
  /* ── Dropdown ── */
89
91
 
90
- function ModelDropdown({ models, value, onChange, placeholder = 'Choose a model...' }: { models: { id: string; label: string }[]; value: string; onChange: (id: string) => void; placeholder?: string }) {
92
+ function ModelDropdown({ models, value, onChange, placeholder = 'Choose a model...', menuMaxHeight = 'max-h-[320px]' }: { models: { id: string; label: string }[]; value: string; onChange: (id: string) => void; placeholder?: string; menuMaxHeight?: string }) {
91
93
  const [open, setOpen] = useState(false);
92
94
  const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
93
95
  const btnRef = useRef<HTMLButtonElement>(null);
@@ -135,7 +137,7 @@ function ModelDropdown({ models, value, onChange, placeholder = 'Choose a model.
135
137
  <div
136
138
  ref={menuRef}
137
139
  style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width, zIndex: 1000 }}
138
- className="bg-[#222] border border-white/[0.08] rounded-xl shadow-xl py-1 max-h-[320px] overflow-y-auto"
140
+ className={`bg-[#222] border border-white/[0.08] rounded-xl shadow-xl py-1 ${menuMaxHeight} overflow-y-auto`}
139
141
  >
140
142
  {models.map((m) => (
141
143
  <button
@@ -215,7 +217,7 @@ function GoToMenu({ onJump }: { onJump: (step: number) => void }) {
215
217
  <div
216
218
  ref={menuRef}
217
219
  style={{ position: 'fixed', top: pos.top, right: pos.right, zIndex: 1000 }}
218
- className="bg-[#222] border border-white/[0.08] rounded-xl shadow-xl py-1 min-w-[200px]"
220
+ className="bg-[#222] border border-white/[0.08] rounded-xl shadow-xl py-1 min-w-[200px] max-h-[188px] overflow-y-auto"
219
221
  >
220
222
  {SETTINGS_SCREENS.map((s) => (
221
223
  <button
@@ -242,6 +244,7 @@ interface EnvCache {
242
244
  values: Record<string, string>;
243
245
  originals: Record<string, string>;
244
246
  revealed: string[];
247
+ removed: string[];
245
248
  newRows: { id: number; name: string; value: string }[];
246
249
  newRowSeq: number;
247
250
  saved: boolean;
@@ -266,6 +269,7 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
266
269
  const [values, setValues] = useState<Record<string, string>>(cached?.values ?? {});
267
270
  const [originals, setOriginals] = useState<Record<string, string>>(cached?.originals ?? {});
268
271
  const [revealed, setRevealed] = useState<Set<string>>(new Set(cached?.revealed ?? []));
272
+ const [removed, setRemoved] = useState<Set<string>>(new Set(cached?.removed ?? []));
269
273
  const [newRows, setNewRows] = useState<{ id: number; name: string; value: string }[]>(cached?.newRows ?? []);
270
274
  const newRowId = useRef(cached?.newRowSeq ?? 0);
271
275
  const [saving, setSaving] = useState(false);
@@ -295,12 +299,14 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
295
299
  // Skip while loading so a navigate-away mid-fetch leaves the cache empty → refetch on return.
296
300
  useEffect(() => {
297
301
  if (loading) return;
298
- cacheRef.current = { groups, values, originals, revealed: Array.from(revealed), newRows, newRowSeq: newRowId.current, saved };
302
+ cacheRef.current = { groups, values, originals, revealed: Array.from(revealed), removed: Array.from(removed), newRows, newRowSeq: newRowId.current, saved };
299
303
  });
300
304
 
301
305
  const validNewRows = newRows.filter((r) => r.name.trim() && r.value.trim() && ENV_NAME_RE.test(r.name.trim()));
302
- const changedExisting = Object.keys(values).filter((k) => values[k] !== originals[k]);
303
- const dirty = changedExisting.length > 0 || validNewRows.length > 0;
306
+ const removedExisting = [...removed].filter((k) => k in originals);
307
+ // An edit to a var that's also marked for removal doesn't count — removal wins.
308
+ const changedExisting = Object.keys(values).filter((k) => values[k] !== originals[k] && !removed.has(k));
309
+ const dirty = changedExisting.length > 0 || validNewRows.length > 0 || removedExisting.length > 0;
304
310
 
305
311
  const toggleReveal = (name: string) => {
306
312
  setRevealed((prev) => {
@@ -310,29 +316,41 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
310
316
  });
311
317
  };
312
318
 
319
+ const toggleRemoved = (name: string) => {
320
+ setSaved(false);
321
+ setRemoved((prev) => {
322
+ const next = new Set(prev);
323
+ if (next.has(name)) next.delete(name); else next.add(name);
324
+ return next;
325
+ });
326
+ };
327
+
313
328
  const handleSave = async () => {
314
329
  if (!dirty || saving) return;
315
330
  setSaving(true); setSaveError(''); setSaved(false);
316
331
  const vars: Record<string, string> = {};
317
332
  for (const k of changedExisting) vars[k] = values[k];
318
333
  for (const r of validNewRows) vars[r.name.trim()] = r.value.trim();
334
+ const removeList = removedExisting;
319
335
  try {
320
336
  const res = await authFetch('/api/env', {
321
337
  method: 'POST',
322
338
  headers: { 'Content-Type': 'application/json' },
323
- body: JSON.stringify({ vars }),
339
+ body: JSON.stringify({ vars, remove: removeList }),
324
340
  });
325
341
  if (!res.ok) {
326
342
  const d = await res.json().catch(() => ({ error: 'Save failed' }));
327
343
  throw new Error(d.error || 'Save failed');
328
344
  }
329
- // Snapshot of values including the just-added rows, so `dirty` resets after save.
345
+ // Snapshot of values including the just-added rows and minus the removed ones, so `dirty`
346
+ // resets after save.
330
347
  const merged = { ...values };
331
348
  for (const r of validNewRows) merged[r.name.trim()] = r.value.trim();
332
- // Surface newly-added vars under a "Custom" group in the UI (the writer appends them
333
- // to the file; on next load they'll re-group under whatever section precedes them).
349
+ for (const k of removeList) delete merged[k];
350
+ // Drop removed vars from the displayed groups; surface newly-added vars under a "Custom" group
351
+ // (the writer appends them; on next load they'll re-group under whatever section precedes them).
334
352
  setGroups((prev) => {
335
- const next = prev.map((g) => ({ ...g, vars: [...g.vars] }));
353
+ let next = prev.map((g) => ({ ...g, vars: g.vars.filter((v) => !removed.has(v.name)) }));
336
354
  const existing = new Set(next.flatMap((g) => g.vars.map((v) => v.name)));
337
355
  const toAdd = validNewRows.map((r) => r.name.trim()).filter((nm) => !existing.has(nm));
338
356
  if (toAdd.length) {
@@ -340,11 +358,12 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
340
358
  if (!custom) { custom = { title: 'Custom', vars: [] }; next.push(custom); }
341
359
  for (const nm of toAdd) custom.vars.push({ name: nm, value: merged[nm] });
342
360
  }
343
- return next;
361
+ return next.filter((g) => g.vars.length > 0); // hide groups emptied by removal
344
362
  });
345
363
  setValues(merged);
346
364
  setOriginals(merged);
347
365
  setNewRows([]);
366
+ setRemoved(new Set());
348
367
  setSaved(true);
349
368
  setSaving(false);
350
369
  } catch (err: any) {
@@ -387,34 +406,60 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
387
406
  </div>
388
407
  <div className="space-y-2.5">
389
408
  {g.vars.map((v) => {
390
- const changed = values[v.name] !== originals[v.name];
409
+ const isRemoved = removed.has(v.name);
410
+ const changed = !isRemoved && values[v.name] !== originals[v.name];
391
411
  const show = revealed.has(v.name);
392
412
  return (
393
413
  <div key={v.name}>
394
414
  <label className="flex items-center gap-1.5 text-[11px] text-white/40 mb-1">
395
- <code className="font-mono text-white/50">{v.name}</code>
415
+ <code className={`font-mono ${isRemoved ? 'text-white/30 line-through' : 'text-white/50'}`}>{v.name}</code>
396
416
  {changed && <span className="text-[#0069FE] text-[10px] font-medium">• edited</span>}
417
+ {isRemoved && <span className="text-red-400/80 text-[10px] font-medium">• will be removed</span>}
397
418
  </label>
398
- <div className="relative">
399
- <input
400
- type={show ? 'text' : 'password'}
401
- value={values[v.name] ?? ''}
402
- onChange={(e) => setValues((p) => ({ ...p, [v.name]: e.target.value }))}
403
- autoComplete="off"
404
- spellCheck={false}
405
- data-1p-ignore
406
- data-lpignore="true"
407
- className="w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl pl-4 pr-10 py-2.5 text-[13px] font-mono outline-none focus:border-[#0069FE]/30 transition-colors"
408
- />
409
- <button
410
- type="button"
411
- onClick={() => toggleReveal(v.name)}
412
- className="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
413
- aria-label={show ? 'Hide value' : 'Reveal value'}
414
- >
415
- {show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
416
- </button>
417
- </div>
419
+ {isRemoved ? (
420
+ <div className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-red-500/20 bg-red-500/[0.04]">
421
+ <span className="flex-1 text-[13px] text-white/30 font-mono">••••••••</span>
422
+ <button
423
+ type="button"
424
+ onClick={() => toggleRemoved(v.name)}
425
+ className="text-[11px] text-white/50 hover:text-white/80 transition-colors"
426
+ >
427
+ Undo
428
+ </button>
429
+ </div>
430
+ ) : (
431
+ <div className="flex items-center gap-2">
432
+ <div className="relative flex-1">
433
+ <input
434
+ type={show ? 'text' : 'password'}
435
+ value={values[v.name] ?? ''}
436
+ onChange={(e) => setValues((p) => ({ ...p, [v.name]: e.target.value }))}
437
+ autoComplete="off"
438
+ spellCheck={false}
439
+ data-1p-ignore
440
+ data-lpignore="true"
441
+ className="w-full bg-white/[0.03] border border-white/[0.08] text-white rounded-xl pl-4 pr-10 py-2.5 text-[13px] font-mono outline-none focus:border-[#0069FE]/30 transition-colors"
442
+ />
443
+ <button
444
+ type="button"
445
+ onClick={() => toggleReveal(v.name)}
446
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
447
+ aria-label={show ? 'Hide value' : 'Reveal value'}
448
+ >
449
+ {show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
450
+ </button>
451
+ </div>
452
+ <button
453
+ type="button"
454
+ onClick={() => toggleRemoved(v.name)}
455
+ className="shrink-0 p-2 text-white/25 hover:text-red-400 hover:bg-red-500/[0.06] rounded-lg transition-colors"
456
+ title="Remove variable"
457
+ aria-label="Remove variable"
458
+ >
459
+ <Trash2 className="h-4 w-4" />
460
+ </button>
461
+ </div>
462
+ )}
418
463
  </div>
419
464
  );
420
465
  })}
@@ -489,7 +534,7 @@ function EnvSettings({ cacheRef }: { cacheRef: MutableRefObject<EnvCache | null>
489
534
  <div className="mt-4 flex items-start gap-2.5 bg-amber-500/[0.06] border border-amber-500/20 rounded-xl px-4 py-3">
490
535
  <TriangleAlert className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
491
536
  <p className="text-amber-300/80 text-[12px] leading-relaxed">
492
- API key change detected. Your workspace backend will be restarted when you save.
537
+ Changes detected. Your workspace backend will be restarted when you save.
493
538
  </p>
494
539
  </div>
495
540
  )}
@@ -534,7 +579,6 @@ interface CronView {
534
579
  id: string;
535
580
  schedule: string;
536
581
  task: string;
537
- enabled: boolean;
538
582
  oneShot?: boolean;
539
583
  paused?: boolean;
540
584
  hasTaskFile: boolean;
@@ -740,9 +784,7 @@ function PulseSection({ pulse, original, setPulse, onPulseSaved }: {
740
784
  />
741
785
  </div>
742
786
  </div>
743
- <p className="text-white/35 text-[12px] mt-2 leading-relaxed">
744
- No pulses fire between these times (your computer's local time). Spanning midnight is fine — e.g. 22:00 → 07:00.
745
- </p>
787
+ <p className="text-white/35 text-[12px] mt-2 leading-relaxed">No pulses fire between these times.</p>
746
788
  </div>
747
789
 
748
790
  {saved && !dirty && (
@@ -777,8 +819,14 @@ function CronsSection({ crons, onChanged, onLocalUpdate }: {
777
819
  const [confirmId, setConfirmId] = useState<string | null>(null);
778
820
  const [downloading, setDownloading] = useState<Record<string, boolean>>({});
779
821
  const [actionError, setActionError] = useState('');
822
+ const [expanded, setExpanded] = useState<Set<string>>(new Set()); // collapsed by default
780
823
 
781
824
  const setRowBusy = (id: string, v: 'pause' | 'delete' | undefined) => setBusy((p) => ({ ...p, [id]: v }));
825
+ const toggleExpanded = (id: string) => setExpanded((prev) => {
826
+ const next = new Set(prev);
827
+ if (next.has(id)) next.delete(id); else next.add(id);
828
+ return next;
829
+ });
782
830
 
783
831
  const handleTogglePause = async (c: CronView) => {
784
832
  if (busy[c.id]) return;
@@ -869,105 +917,94 @@ function CronsSection({ crons, onChanged, onLocalUpdate }: {
869
917
  ) : (
870
918
  crons.map((c) => {
871
919
  const paused = !!c.paused;
872
- const disabled = c.enabled === false;
873
- const parked = paused || disabled;
874
920
  const rowBusy = busy[c.id];
921
+ const isOpen = expanded.has(c.id);
875
922
  const nextStr = formatNextRun(c.nextRun);
876
923
  return (
877
- <div key={c.id} className={`rounded-xl border p-3.5 transition-colors ${parked ? 'border-white/[0.05] bg-white/[0.015] opacity-60' : 'border-white/[0.06] bg-white/[0.02]'}`}>
878
- <div className="flex items-start gap-3">
879
- <div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${c.oneShot ? 'bg-amber-500/10' : 'bg-[#0069FE]/10'}`}>
880
- {c.oneShot ? <Zap className="h-[18px] w-[18px] text-amber-400" /> : <Clock className="h-[18px] w-[18px] text-[#0069FE]" />}
881
- </div>
882
- <div className="flex-1 min-w-0">
883
- <div className="flex items-start justify-between gap-2">
884
- <p className="text-[13px] text-white leading-snug">{c.task}</p>
885
- <div className="flex items-center gap-1 shrink-0">
886
- {c.oneShot && (
887
- <span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-amber-300/90 text-[10px] font-medium px-2 py-0.5">
888
- <Zap className="h-2.5 w-2.5" />One-time
889
- </span>
890
- )}
891
- {paused && (
892
- <span className="inline-flex items-center gap-1 rounded-full bg-white/[0.06] border border-white/[0.08] text-white/50 text-[10px] font-medium px-2 py-0.5">
893
- <Pause className="h-2.5 w-2.5" />Paused
894
- </span>
895
- )}
896
- {disabled && !paused && (
897
- <span className="inline-flex items-center gap-1 rounded-full bg-white/[0.06] border border-white/[0.08] text-white/50 text-[10px] font-medium px-2 py-0.5">
898
- Disabled
899
- </span>
900
- )}
901
- </div>
902
- </div>
903
-
904
- <div className="mt-1.5 flex items-center gap-1.5 text-[12px] text-white/45">
905
- <Clock className="h-3 w-3 shrink-0 text-white/30" />
906
- <span className="truncate">{c.description}</span>
907
- </div>
908
-
909
- <div className="mt-1.5">
910
- {parked ? (
911
- <span className="text-[11px] text-white/30">{paused ? "Paused — won't run" : "Disabled — won't run"}</span>
912
- ) : nextStr ? (
913
- <span className="inline-flex items-center gap-1 rounded-md bg-[#0069FE]/[0.08] border border-[#0069FE]/15 text-[#4AA8FF] text-[11px] font-medium px-2 py-0.5">
914
- Next: {nextStr}
915
- </span>
916
- ) : (
917
- <span className="text-[11px] text-white/30">No upcoming run</span>
918
- )}
924
+ <div key={c.id} className={`rounded-xl border transition-colors ${paused ? 'border-white/[0.05] bg-white/[0.015]' : 'border-white/[0.06] bg-white/[0.02]'}`}>
925
+ {/* Header row — title + actions (always visible) */}
926
+ <div className="flex items-center gap-2 p-2.5">
927
+ <button
928
+ type="button"
929
+ onClick={() => toggleExpanded(c.id)}
930
+ className="flex items-center gap-2.5 flex-1 min-w-0 text-left"
931
+ >
932
+ <div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${paused ? 'bg-white/[0.04]' : c.oneShot ? 'bg-amber-500/10' : 'bg-[#0069FE]/10'}`}>
933
+ {c.oneShot
934
+ ? <Zap className={`h-4 w-4 ${paused ? 'text-white/40' : 'text-amber-400'}`} />
935
+ : <Clock className={`h-4 w-4 ${paused ? 'text-white/40' : 'text-[#0069FE]'}`} />}
919
936
  </div>
920
- </div>
921
- </div>
922
-
923
- <div className="mt-3 flex items-center justify-between gap-2">
924
- {c.hasTaskFile ? (
925
- <button
926
- type="button" onClick={() => handleDownloadTask(c)} disabled={downloading[c.id]}
927
- className="group inline-flex items-center gap-1.5 rounded-md bg-white/[0.04] hover:bg-white/[0.07] border border-white/[0.06] hover:border-white/[0.12] text-white/50 hover:text-white/80 text-[11px] font-mono px-2 py-1 transition-colors disabled:opacity-50"
928
- title="Download task instructions"
929
- >
930
- {downloading[c.id] ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <FileText className="h-3 w-3 shrink-0" />}
931
- {c.id}.md
932
- <Download className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
933
- </button>
934
- ) : (<span />)}
937
+ <span className={`flex-1 min-w-0 truncate text-[13px] ${paused ? 'text-white/45' : 'text-white'}`}>{c.task || '(untitled task)'}</span>
938
+ {paused && (
939
+ <span className="shrink-0 text-[10px] font-medium text-white/40 bg-white/[0.05] rounded-full px-2 py-0.5">Paused</span>
940
+ )}
941
+ </button>
935
942
 
936
943
  {confirmId === c.id ? (
937
- <div className="flex items-center gap-1.5">
938
- <span className="text-[11px] text-white/50 mr-0.5">Delete?</span>
944
+ <div className="flex items-center gap-1 shrink-0">
945
+ <span className="text-[11px] text-white/45 mr-0.5">Delete?</span>
939
946
  <button
940
947
  type="button" onClick={() => handleDelete(c)} disabled={rowBusy === 'delete'}
941
- className="inline-flex items-center gap-1 rounded-md bg-red-500/15 hover:bg-red-500/25 border border-red-500/30 text-red-300 text-[11px] font-medium px-2.5 py-1 transition-colors disabled:opacity-50"
948
+ className="inline-flex items-center gap-1 rounded-md bg-red-500/15 hover:bg-red-500/25 border border-red-500/30 text-red-300 text-[11px] font-medium px-2 py-1 transition-colors disabled:opacity-50"
942
949
  >
943
- {rowBusy === 'delete' ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
944
- Delete
950
+ {rowBusy === 'delete' ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}Delete
945
951
  </button>
946
952
  <button
947
953
  type="button" onClick={() => setConfirmId(null)} disabled={rowBusy === 'delete'}
948
- className="rounded-md text-white/40 hover:text-white/70 text-[11px] px-2 py-1 transition-colors"
954
+ className="rounded-md text-white/40 hover:text-white/70 text-[11px] px-1.5 py-1 transition-colors"
949
955
  >
950
956
  Cancel
951
957
  </button>
952
958
  </div>
953
959
  ) : (
954
- <div className="flex items-center gap-1">
960
+ <div className="flex items-center gap-0.5 shrink-0">
955
961
  <button
956
962
  type="button" onClick={() => handleTogglePause(c)} disabled={!!rowBusy}
957
- className="inline-flex items-center gap-1 rounded-md text-white/45 hover:text-white/80 hover:bg-white/[0.04] text-[11px] font-medium px-2 py-1 transition-colors disabled:opacity-50"
963
+ title={paused ? 'Resume' : 'Pause'} aria-label={paused ? 'Resume' : 'Pause'}
964
+ className="p-1.5 rounded-lg text-white/40 hover:text-white/80 hover:bg-white/[0.05] transition-colors disabled:opacity-50"
958
965
  >
959
- {rowBusy === 'pause' ? <LoaderCircle className="h-3 w-3 animate-spin" /> : paused ? <Play className="h-3 w-3" /> : <Pause className="h-3 w-3" />}
960
- {paused ? 'Resume' : 'Pause'}
966
+ {rowBusy === 'pause' ? <LoaderCircle className="h-4 w-4 animate-spin" /> : paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
961
967
  </button>
962
968
  <button
963
969
  type="button" onClick={() => setConfirmId(c.id)} disabled={!!rowBusy}
964
- className="inline-flex items-center gap-1 rounded-md text-white/30 hover:text-red-400 hover:bg-red-500/[0.06] text-[11px] font-medium px-2 py-1 transition-colors disabled:opacity-50"
970
+ title="Delete" aria-label="Delete"
971
+ className="p-1.5 rounded-lg text-white/30 hover:text-red-400 hover:bg-red-500/[0.06] transition-colors disabled:opacity-50"
965
972
  >
966
- <Trash2 className="h-3 w-3" />Delete
973
+ <Trash2 className="h-4 w-4" />
974
+ </button>
975
+ <button
976
+ type="button" onClick={() => toggleExpanded(c.id)}
977
+ title={isOpen ? 'Collapse' : 'Expand'} aria-label={isOpen ? 'Collapse' : 'Expand'}
978
+ className="p-1.5 rounded-lg text-white/30 hover:text-white/70 hover:bg-white/[0.05] transition-colors"
979
+ >
980
+ <ChevronDown className={`h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
967
981
  </button>
968
982
  </div>
969
983
  )}
970
984
  </div>
985
+
986
+ {/* Expanded detail — schedule, next run, task file */}
987
+ {isOpen && (
988
+ <div className="px-2.5 pb-3 pl-[52px] -mt-0.5">
989
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-white/45">
990
+ <span className="inline-flex items-center gap-1.5"><Clock className="h-3 w-3 text-white/30 shrink-0" />{c.description}</span>
991
+ {c.oneShot && <span className="text-amber-300/70">· One-time</span>}
992
+ {/* paused is already shown by the header pill — don't repeat it here */}
993
+ {!paused && nextStr && <span className="text-[#4AA8FF]">· Next: {nextStr}</span>}
994
+ </div>
995
+ {c.hasTaskFile && (
996
+ <button
997
+ type="button" onClick={() => handleDownloadTask(c)} disabled={downloading[c.id]}
998
+ className="group mt-2 inline-flex items-center gap-1.5 rounded-md bg-white/[0.04] hover:bg-white/[0.07] border border-white/[0.06] hover:border-white/[0.12] text-white/50 hover:text-white/80 text-[11px] font-mono px-2 py-1 transition-colors disabled:opacity-50"
999
+ title="Download task instructions"
1000
+ >
1001
+ {downloading[c.id] ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <FileText className="h-3 w-3 shrink-0" />}
1002
+ {c.id}.md
1003
+ <Download className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
1004
+ </button>
1005
+ )}
1006
+ </div>
1007
+ )}
971
1008
  </div>
972
1009
  );
973
1010
  })
@@ -2087,6 +2124,7 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
2087
2124
  models={SETTINGS_SCREENS.map((s) => ({ id: String(s.step), label: s.label }))}
2088
2125
  value=""
2089
2126
  placeholder="Select a setting…"
2127
+ menuMaxHeight="max-h-[188px]"
2090
2128
  onChange={(id) => setStep(Number(id))}
2091
2129
  />
2092
2130
  </div>
@@ -3477,6 +3515,21 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
3477
3515
  {/* ── Auth flow: Anthropic ── */}
3478
3516
  {provider === 'anthropic' && (
3479
3517
  <div className="space-y-2.5">
3518
+ {/* Anthropic third-party usage policy notice — shown to anyone considering Claude. */}
3519
+ <div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] px-4 py-3.5">
3520
+ <div className="flex items-center gap-2 mb-2">
3521
+ <TriangleAlert className="h-4 w-4 text-amber-400 shrink-0" />
3522
+ <h3 className="text-[12.5px] font-semibold text-amber-200/90">Anthropic Third-Party App Policy Update</h3>
3523
+ </div>
3524
+ <div className="space-y-2 text-amber-100/70 text-[12px] leading-relaxed">
3525
+ <p>Starting June 15, 2026, Anthropic will provide a separate Third-Party App credit equal to the amount you pay for your subscription.</p>
3526
+ <p>For example, if you have the Max 5x plan at $100/month, you will receive $100 in credits to use with third-party tools like Bloby.</p>
3527
+ <p>Unfortunately, this is only a fraction of the usage Bloby users had before. We don&apos;t control Anthropic&apos;s rules, but we do need to follow them.</p>
3528
+ <p>The best alternative right now is a <span className="font-medium text-amber-100/90">ChatGPT subscription</span>, which also offers $100 and $200 plans with much higher usage limits for Bloby.</p>
3529
+ <p>In the short term, Bloby will be optimized for ChatGPT. In the long term, we are building our own model harness so Bloby has more control, more flexibility, and does not depend too heavily on providers that can change their rules at any moment.</p>
3530
+ </div>
3531
+ </div>
3532
+
3480
3533
  {isConnected && (
3481
3534
  <div className="space-y-2.5">
3482
3535
  <div className="bg-emerald-500/8 border border-emerald-500/15 rounded-lg px-3.5 py-2.5">
@@ -213,6 +213,11 @@ async function buildConversationOptions(
213
213
 
214
214
  return {
215
215
  model,
216
+ // Reasoning effort. 'high' = deep reasoning while staying more token-efficient
217
+ // than the CLI's xhigh default on Opus 4.7/4.8 — meaningful given Anthropic's
218
+ // tighter third-party usage limits. Supported on Opus 4.6+/Sonnet 4.6; silently
219
+ // ignored by models without effort support.
220
+ effort: 'high',
216
221
  cwd: WORKSPACE_DIR,
217
222
  permissionMode: 'bypassPermissions',
218
223
  allowDangerouslySkipPermissions: true,
@@ -648,6 +653,7 @@ export async function startBlobyAgentQuery(
648
653
  prompt: sdkPrompt,
649
654
  options: {
650
655
  model,
656
+ effort: 'high', // see buildConversationOptions — token-efficient deep reasoning
651
657
  cwd: WORKSPACE_DIR,
652
658
  permissionMode: 'bypassPermissions',
653
659
  allowDangerouslySkipPermissions: true,
@@ -762,6 +768,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
762
768
  prompt: req.message,
763
769
  options: {
764
770
  cwd: WORKSPACE_DIR,
771
+ effort: 'high', // see buildConversationOptions — token-efficient deep reasoning
765
772
  permissionMode: 'bypassPermissions',
766
773
  allowDangerouslySkipPermissions: true,
767
774
  maxTurns,
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { log } from '../../../shared/logger.js';
14
14
  import { WORKSPACE_DIR } from '../../../shared/paths.js';
15
- import type { SavedFile } from '../file-saver.js';
15
+ import type { SavedFile } from '../../file-saver.js';
16
16
  import { assembleSystemPrompt } from '../../../worker/prompts/prompt-assembler.js';
17
17
  import fs from 'fs';
18
18
  import path from 'path';
@@ -12,7 +12,14 @@ export function safeResolve(cwd: string, requested: string): string {
12
12
  if (!requested || typeof requested !== 'string') {
13
13
  throw new Error('Missing file path');
14
14
  }
15
- const root = fs.realpathSync.native ? fs.realpathSync(cwd) : path.resolve(cwd);
15
+ // Canonicalize cwd (resolves symlinks) so the traversal check below compares real
16
+ // paths. Falls back to a plain resolve when cwd doesn't exist yet (realpath throws).
17
+ let root: string;
18
+ try {
19
+ root = fs.realpathSync.native(cwd);
20
+ } catch {
21
+ root = path.resolve(cwd);
22
+ }
16
23
  const abs = path.isAbsolute(requested)
17
24
  ? path.normalize(requested)
18
25
  : path.normalize(path.join(root, requested));