@aion0/forge 0.4.16 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +27 -2
  2. package/RELEASE_NOTES.md +21 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2245 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +254 -0
  65. package/lib/help-docs/CLAUDE.md +7 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1914 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +814 -0
  88. package/middleware.ts +1 -0
  89. package/next-env.d.ts +1 -1
  90. package/package.json +4 -1
  91. package/src/config/index.ts +12 -1
  92. package/src/core/db/database.ts +1 -0
  93. package/start.sh +7 -0
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
 
5
5
  function SecretInput({ value, onChange, placeholder, className }: {
6
6
  value: string;
@@ -244,6 +244,8 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
244
244
  const [tunnelPassword, setTunnelPassword] = useState('');
245
245
  const [tunnelPasswordError, setTunnelPasswordError] = useState('');
246
246
  const [editingSecret, setEditingSecret] = useState<{ field: string; label: string } | null>(null);
247
+ const [hasUnsaved, setHasUnsaved] = useState(false);
248
+ const origSettingsRef = useRef('');
247
249
 
248
250
  const refreshTunnel = useCallback(() => {
249
251
  fetch('/api/tunnel').then(r => r.json()).then(setTunnel).catch(() => {});
@@ -254,6 +256,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
254
256
  const status = data._secretStatus || {};
255
257
  delete data._secretStatus;
256
258
  setSettings(data);
259
+ origSettingsRef.current = JSON.stringify(data);
257
260
  setSecretStatus(status);
258
261
  });
259
262
  }, []);
@@ -276,10 +279,19 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
276
279
  headers: { 'Content-Type': 'application/json' },
277
280
  body: JSON.stringify(settings),
278
281
  });
282
+ origSettingsRef.current = JSON.stringify(settings);
283
+ setHasUnsaved(false);
279
284
  setSaved(true);
280
285
  setTimeout(() => setSaved(false), 2000);
281
286
  };
282
287
 
288
+ // Track unsaved changes
289
+ useEffect(() => {
290
+ if (origSettingsRef.current) {
291
+ setHasUnsaved(JSON.stringify(settings) !== origSettingsRef.current);
292
+ }
293
+ }, [settings]);
294
+
283
295
  const saveSecret = async (field: string, adminPassword: string, newValue: string): Promise<string | null> => {
284
296
  const res = await fetch('/api/settings', {
285
297
  method: 'PUT',
@@ -308,7 +320,10 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
308
320
  };
309
321
 
310
322
  return (
311
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
323
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => {
324
+ if (hasUnsaved && !confirm('You have unsaved changes. Close anyway?')) return;
325
+ onClose();
326
+ }}>
312
327
  <div
313
328
  className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[500px] max-h-[80vh] overflow-y-auto p-5 space-y-5"
314
329
  onClick={e => e.stopPropagation()}
@@ -364,13 +379,13 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
364
379
  Markdown document directories (e.g. Obsidian vaults). Shown in the Docs tab.
365
380
  </p>
366
381
 
367
- {(settings.docRoots || []).map(root => (
382
+ {(settings.docRoots || []).map((root: string) => (
368
383
  <div key={root} className="flex items-center gap-2">
369
384
  <span className="flex-1 text-xs px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded font-mono truncate">
370
385
  {root}
371
386
  </span>
372
387
  <button
373
- onClick={() => setSettings({ ...settings, docRoots: settings.docRoots.filter(r => r !== root) })}
388
+ onClick={() => setSettings({ ...settings, docRoots: settings.docRoots.filter((r: string) => r !== root) })}
374
389
  className="text-[10px] px-2 py-1 text-[var(--red)] hover:bg-[var(--red)] hover:text-white rounded transition-colors"
375
390
  >
376
391
  Remove
@@ -405,63 +420,11 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
405
420
  Add
406
421
  </button>
407
422
  </div>
423
+ <DocsAgentSelect settings={settings} setSettings={setSettings} />
408
424
  </div>
409
425
 
410
- {/* Claude Path */}
411
- <div className="space-y-2">
412
- <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
413
- Claude Code Path
414
- </label>
415
- <div className="flex gap-2">
416
- <input
417
- value={settings.claudePath}
418
- onChange={e => setSettings({ ...settings, claudePath: e.target.value })}
419
- placeholder="Auto-detect or enter path manually"
420
- className="flex-1 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
421
- />
422
- <button
423
- type="button"
424
- onClick={async () => {
425
- try {
426
- const res = await fetch('/api/detect-cli');
427
- const data = await res.json();
428
- const claude = data.tools?.find((t: any) => t.name === 'claude');
429
- if (claude?.path) {
430
- setSettings({ ...settings, claudePath: claude.path });
431
- } else {
432
- const hint = claude?.installHint || 'npm install -g @anthropic-ai/claude-code';
433
- alert(`Claude Code not found.\n\nInstall:\n ${hint}`);
434
- }
435
- } catch { alert('Detection failed'); }
436
- }}
437
- className="text-[10px] px-2 py-1.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors shrink-0"
438
- >
439
- Detect
440
- </button>
441
- </div>
442
- <p className={`text-[9px] ${settings.claudePath ? 'text-[var(--text-secondary)]' : 'text-[var(--yellow)]'}`}>
443
- {settings.claudePath
444
- ? 'Click Detect to re-scan, or edit manually.'
445
- : 'Not configured. Click Detect or run `which claude` in terminal to find the path.'}
446
- </p>
447
- </div>
448
-
449
- {/* Claude Home Directory */}
450
- <div className="space-y-2">
451
- <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
452
- Claude Home Directory
453
- </label>
454
- <input
455
- type="text"
456
- value={(settings as any).claudeHome || ''}
457
- onChange={e => setSettings({ ...settings, claudeHome: e.target.value } as any)}
458
- placeholder="~/.claude (default)"
459
- className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono"
460
- />
461
- <p className="text-[9px] text-[var(--text-secondary)]">
462
- Where Claude Code stores skills, commands, and sessions. Leave empty for default (~/.claude).
463
- </p>
464
- </div>
426
+ {/* Agents */}
427
+ <AgentsSection settings={settings} setSettings={setSettings} />
465
428
 
466
429
  {/* Telegram Notifications */}
467
430
  <div className="space-y-2">
@@ -528,74 +491,10 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
528
491
  </button>
529
492
  )}
530
493
  </div>
494
+ <TelegramAgentSelect settings={settings} setSettings={setSettings} />
531
495
  </div>
532
496
 
533
- {/* Model Settings */}
534
- <div className="space-y-2">
535
- <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
536
- Models
537
- </label>
538
- <p className="text-[10px] text-[var(--text-secondary)]">
539
- Claude model for each feature. Uses your Claude Code subscription. Options: sonnet, opus, haiku, or default (subscription default).
540
- </p>
541
- <div className="grid grid-cols-3 gap-2">
542
- <div>
543
- <label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">Tasks</label>
544
- <select
545
- value={settings.taskModel || 'sonnet'}
546
- onChange={e => setSettings({ ...settings, taskModel: e.target.value })}
547
- className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
548
- >
549
- <option value="default">Default</option>
550
- <option value="sonnet">Sonnet</option>
551
- <option value="opus">Opus</option>
552
- <option value="haiku">Haiku</option>
553
- </select>
554
- </div>
555
- <div>
556
- <label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">Pipelines</label>
557
- <select
558
- value={settings.pipelineModel || 'sonnet'}
559
- onChange={e => setSettings({ ...settings, pipelineModel: e.target.value })}
560
- className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
561
- >
562
- <option value="default">Default</option>
563
- <option value="sonnet">Sonnet</option>
564
- <option value="opus">Opus</option>
565
- <option value="haiku">Haiku</option>
566
- </select>
567
- </div>
568
- <div>
569
- <label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">Telegram</label>
570
- <select
571
- value={settings.telegramModel || 'sonnet'}
572
- onChange={e => setSettings({ ...settings, telegramModel: e.target.value })}
573
- className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)]"
574
- >
575
- <option value="default">Default</option>
576
- <option value="sonnet">Sonnet</option>
577
- <option value="opus">Opus</option>
578
- <option value="haiku">Haiku</option>
579
- </select>
580
- </div>
581
- </div>
582
- </div>
583
497
 
584
- {/* Permissions */}
585
- <div className="space-y-2">
586
- <label className="flex items-center gap-2 text-xs text-[var(--text-primary)] cursor-pointer">
587
- <input
588
- type="checkbox"
589
- checked={settings.skipPermissions || false}
590
- onChange={e => setSettings({ ...settings, skipPermissions: e.target.checked })}
591
- className="rounded"
592
- />
593
- Skip permissions check (--dangerously-skip-permissions)
594
- </label>
595
- <p className="text-[9px] text-[var(--text-secondary)]">
596
- When enabled, all Claude Code tasks and pipelines run without permission prompts. Useful for background automation but less safe.
597
- </p>
598
- </div>
599
498
 
600
499
  {/* Notification Retention */}
601
500
  <div className="space-y-2">
@@ -880,3 +779,770 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
880
779
  </div>
881
780
  );
882
781
  }
782
+
783
+ // ─── Agents Configuration Section ─────────────────────────────
784
+
785
+ interface AgentEntry {
786
+ id: string;
787
+ name: string;
788
+ path: string;
789
+ enabled: boolean;
790
+ type: string;
791
+ taskFlags: string;
792
+ interactiveCmd: string;
793
+ resumeFlag: string;
794
+ outputFormat: string;
795
+ models: { terminal: string; task: string; telegram: string; help: string; mobile: string };
796
+ skipPermissionsFlag: string;
797
+ requiresTTY: boolean;
798
+ detected: boolean;
799
+ isProfile?: boolean;
800
+ base?: string;
801
+ backendType?: string;
802
+ }
803
+
804
+ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
805
+ id: string; cfg: any; inputClass: string;
806
+ onUpdate: (cfg: any) => void; onDelete: () => void;
807
+ }) {
808
+ const [expanded, setExpanded] = useState(false);
809
+ const isApi = cfg.type === 'api';
810
+ const summary = isApi
811
+ ? `API: ${cfg.provider || '?'} / ${cfg.model || '?'}`
812
+ : `CLI: ${cfg.base || '?'} / ${cfg.model || cfg.models?.task || 'default'}`;
813
+ const envStr = cfg.env ? Object.entries(cfg.env).map(([k, v]) => `${k}=${v}`).join('\n') : '';
814
+
815
+ return (
816
+ <div className="mb-1 rounded" style={{ background: 'var(--bg-tertiary)' }}>
817
+ <div className="flex items-center gap-2 px-2 py-1.5 cursor-pointer" onClick={() => setExpanded(!expanded)}>
818
+ <span className="text-[8px] text-[var(--text-secondary)]">{expanded ? '▼' : '▶'}</span>
819
+ <span className="text-[9px] text-[var(--accent)] font-mono w-28 truncate">{id}</span>
820
+ <span className="text-[9px] text-[var(--text-secondary)]">{summary}</span>
821
+ <span className="text-[8px] text-[var(--text-secondary)]">{cfg.name || ''}</span>
822
+ <button onClick={(e) => { e.stopPropagation(); onDelete(); }}
823
+ className="text-[9px] text-gray-500 hover:text-red-400 ml-auto">✕</button>
824
+ </div>
825
+ {expanded && (
826
+ <div className="px-3 pb-2 space-y-1.5 border-t border-[var(--border)]">
827
+ <div className="flex gap-2 mt-1.5">
828
+ <div className="flex-1">
829
+ <label className="text-[8px] text-[var(--text-secondary)]">Name</label>
830
+ <input value={cfg.name || ''} onChange={e => onUpdate({ ...cfg, name: e.target.value })} className={inputClass} />
831
+ </div>
832
+ <div className="flex-1">
833
+ <label className="text-[8px] text-[var(--text-secondary)]">Model</label>
834
+ <input value={cfg.model || ''} onChange={e => onUpdate({ ...cfg, model: e.target.value })} className={inputClass} />
835
+ </div>
836
+ </div>
837
+ {isApi ? (
838
+ <div className="flex gap-2">
839
+ <div className="flex-1">
840
+ <label className="text-[8px] text-[var(--text-secondary)]">Provider</label>
841
+ <select value={cfg.provider || 'anthropic'} onChange={e => onUpdate({ ...cfg, provider: e.target.value })} className={inputClass}>
842
+ <option value="anthropic">Anthropic</option>
843
+ <option value="google">Google</option>
844
+ <option value="openai">OpenAI</option>
845
+ <option value="grok">Grok</option>
846
+ </select>
847
+ </div>
848
+ <div className="flex-1">
849
+ <label className="text-[8px] text-[var(--text-secondary)]">API Key (optional)</label>
850
+ <input type="password" value={cfg.apiKey || ''} onChange={e => onUpdate({ ...cfg, apiKey: e.target.value })} className={inputClass} />
851
+ </div>
852
+ </div>
853
+ ) : (
854
+ <>
855
+ <div>
856
+ <label className="text-[8px] text-[var(--text-secondary)]">CLI Type</label>
857
+ <select value={cfg.base || 'claude'} onChange={e => onUpdate({ ...cfg, base: e.target.value, cliType: e.target.value === 'claude' ? 'claude-code' : e.target.value })} className={inputClass}>
858
+ <option value="claude">Claude Code</option>
859
+ <option value="codex">Codex</option>
860
+ <option value="aider">Aider</option>
861
+ <option value="generic">Generic</option>
862
+ </select>
863
+ </div>
864
+ <div>
865
+ <div className="flex items-center gap-2">
866
+ <label className="text-[8px] text-[var(--text-secondary)]">Environment Variables (KEY=VALUE per line)</label>
867
+ {cfg.base && (
868
+ <button onClick={() => {
869
+ const templates: Record<string, string> = {
870
+ claude: 'ANTHROPIC_AUTH_TOKEN=\nANTHROPIC_BASE_URL=\nANTHROPIC_SMALL_FAST_MODEL=\nCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=true\nDISABLE_TELEMETRY=true\nDISABLE_ERROR_REPORTING=true\nDISABLE_AUTOUPDATER=true\nDISABLE_NON_ESSENTIAL_MODEL_CALLS=true',
871
+ codex: 'OPENAI_API_KEY=\nOPENAI_BASE_URL=',
872
+ aider: 'ANTHROPIC_API_KEY=\nOPENAI_API_KEY=',
873
+ };
874
+ const tpl = templates[cfg.base!];
875
+ if (tpl) {
876
+ const env: Record<string, string> = {};
877
+ for (const line of tpl.split('\n')) {
878
+ const eq = line.indexOf('=');
879
+ if (eq > 0) env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
880
+ }
881
+ // Merge with existing (don't overwrite filled values)
882
+ const merged = { ...env, ...(cfg.env || {}) };
883
+ onUpdate({ ...cfg, env: merged });
884
+ }
885
+ }} className="text-[7px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20">
886
+ Fill {cfg.base} template
887
+ </button>
888
+ )}
889
+ </div>
890
+ <textarea
891
+ value={envStr}
892
+ onChange={e => {
893
+ const env: Record<string, string> = {};
894
+ for (const line of e.target.value.split('\n')) {
895
+ const eq = line.indexOf('=');
896
+ if (eq > 0) env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
897
+ }
898
+ onUpdate({ ...cfg, env: Object.keys(env).length > 0 ? env : undefined });
899
+ }}
900
+ rows={5}
901
+ placeholder="ANTHROPIC_AUTH_TOKEN=sk-...\nANTHROPIC_BASE_URL=http://..."
902
+ className={inputClass + ' resize-none font-mono'} />
903
+ </div>
904
+ </>
905
+ )}
906
+ </div>
907
+ )}
908
+ </div>
909
+ );
910
+ }
911
+
912
+ function AddProfileForm({ type, baseAgents, onAdd }: {
913
+ type: 'cli' | 'api';
914
+ baseAgents: AgentEntry[];
915
+ onAdd: (id: string, cfg: any) => void;
916
+ }) {
917
+ const [open, setOpen] = useState(false);
918
+ const [id, setId] = useState('');
919
+ const [name, setName] = useState('');
920
+ const [base, setBase] = useState(baseAgents[0]?.id || 'claude');
921
+ const [model, setModel] = useState('');
922
+ const [provider, setProvider] = useState('anthropic');
923
+ const [envText, setEnvText] = useState('');
924
+ const [apiKey, setApiKey] = useState('');
925
+
926
+ const inputClass = "w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]";
927
+
928
+ // Env var templates per CLI type
929
+ const envTemplates: Record<string, string> = {
930
+ claude: [
931
+ 'ANTHROPIC_AUTH_TOKEN=',
932
+ 'ANTHROPIC_BASE_URL=',
933
+ 'ANTHROPIC_SMALL_FAST_MODEL=',
934
+ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=true',
935
+ 'DISABLE_TELEMETRY=true',
936
+ 'DISABLE_ERROR_REPORTING=true',
937
+ 'DISABLE_AUTOUPDATER=true',
938
+ 'DISABLE_NON_ESSENTIAL_MODEL_CALLS=true',
939
+ ].join('\n'),
940
+ codex: [
941
+ 'OPENAI_API_KEY=',
942
+ 'OPENAI_BASE_URL=',
943
+ ].join('\n'),
944
+ aider: [
945
+ 'ANTHROPIC_API_KEY=',
946
+ 'OPENAI_API_KEY=',
947
+ ].join('\n'),
948
+ };
949
+
950
+ const fillEnvTemplate = () => {
951
+ const tpl = envTemplates[base] || '';
952
+ if (tpl && (!envText.trim() || confirm('Replace current env vars with template?'))) {
953
+ setEnvText(tpl);
954
+ }
955
+ };
956
+
957
+ if (!open) {
958
+ return (
959
+ <button onClick={() => setOpen(true)}
960
+ className="text-[9px] px-2 py-0.5 border border-dashed border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)] mt-1">
961
+ + {type === 'cli' ? 'CLI Profile' : 'API Profile'}
962
+ </button>
963
+ );
964
+ }
965
+
966
+ const parseEnv = (): Record<string, string> | undefined => {
967
+ if (!envText.trim()) return undefined;
968
+ const env: Record<string, string> = {};
969
+ for (const line of envText.split('\n')) {
970
+ const eq = line.indexOf('=');
971
+ if (eq > 0) {
972
+ env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
973
+ }
974
+ }
975
+ return Object.keys(env).length > 0 ? env : undefined;
976
+ };
977
+
978
+ const handleAdd = () => {
979
+ if (!id) return;
980
+ if (type === 'cli') {
981
+ onAdd(id, { base, cliType: base === 'claude' ? 'claude-code' : base, name: name || id, model: model || undefined, env: parseEnv() });
982
+ } else {
983
+ onAdd(id, { type: 'api', name: name || id, provider, model: model || undefined, apiKey: apiKey || undefined });
984
+ }
985
+ setOpen(false);
986
+ setId(''); setName(''); setModel(''); setApiKey(''); setEnvText('');
987
+ };
988
+
989
+ return (
990
+ <div className="mt-2 p-2 rounded border border-[var(--border)] space-y-1.5" style={{ background: 'var(--bg-secondary)' }}>
991
+ <div className="text-[9px] text-[var(--text-secondary)] font-semibold">New {type === 'cli' ? 'CLI' : 'API'} Profile</div>
992
+ <div className="flex gap-2">
993
+ <div className="flex-1">
994
+ <label className="text-[8px] text-[var(--text-secondary)]">Profile ID</label>
995
+ <input value={id} onChange={e => setId(e.target.value.replace(/\s+/g, '-').toLowerCase())} placeholder={type === 'cli' ? 'claude-opus' : 'api-sonnet'} className={inputClass} />
996
+ </div>
997
+ <div className="flex-1">
998
+ <label className="text-[8px] text-[var(--text-secondary)]">Display Name</label>
999
+ <input value={name} onChange={e => setName(e.target.value)} placeholder="Claude Opus" className={inputClass} />
1000
+ </div>
1001
+ </div>
1002
+ {type === 'cli' ? (<>
1003
+ <div className="flex gap-2">
1004
+ <div className="flex-1">
1005
+ <label className="text-[8px] text-[var(--text-secondary)]">CLI Type</label>
1006
+ <select value={base} onChange={e => setBase(e.target.value)}
1007
+ className={inputClass}>
1008
+ <option value="claude">Claude Code</option>
1009
+ <option value="codex">Codex</option>
1010
+ <option value="aider">Aider</option>
1011
+ <option value="generic">Generic</option>
1012
+ </select>
1013
+ </div>
1014
+ <div className="flex-1">
1015
+ <label className="text-[8px] text-[var(--text-secondary)]">Model</label>
1016
+ <input value={model} onChange={e => setModel(e.target.value)} placeholder="claude-opus-4-6" className={inputClass} />
1017
+ </div>
1018
+ </div>
1019
+ <div>
1020
+ <div className="flex items-center gap-2">
1021
+ <label className="text-[8px] text-[var(--text-secondary)]">Environment Variables (KEY=VALUE per line)</label>
1022
+ {envTemplates[base] && (
1023
+ <button onClick={fillEnvTemplate} className="text-[7px] px-1.5 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20">
1024
+ Fill {base} template
1025
+ </button>
1026
+ )}
1027
+ </div>
1028
+ <textarea value={envText} onChange={e => setEnvText(e.target.value)} rows={5}
1029
+ placeholder={envTemplates[base] || 'KEY=VALUE\nKEY2=VALUE2'}
1030
+ className={inputClass + ' resize-none font-mono'} />
1031
+ </div>
1032
+ </>) : (
1033
+ <>
1034
+ <div className="flex gap-2">
1035
+ <div className="flex-1">
1036
+ <label className="text-[8px] text-[var(--text-secondary)]">Provider</label>
1037
+ <select value={provider} onChange={e => setProvider(e.target.value)} className={inputClass}>
1038
+ <option value="anthropic">Anthropic</option>
1039
+ <option value="google">Google</option>
1040
+ <option value="openai">OpenAI</option>
1041
+ <option value="grok">Grok</option>
1042
+ </select>
1043
+ </div>
1044
+ <div className="flex-1">
1045
+ <label className="text-[8px] text-[var(--text-secondary)]">Model</label>
1046
+ <input value={model} onChange={e => setModel(e.target.value)} placeholder="claude-sonnet-4-6" className={inputClass} />
1047
+ </div>
1048
+ </div>
1049
+ <div>
1050
+ <label className="text-[8px] text-[var(--text-secondary)]">API Key (optional, uses provider key if empty)</label>
1051
+ <input type="password" value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder="sk-..." className={inputClass} />
1052
+ </div>
1053
+ </>
1054
+ )}
1055
+ <div className="flex gap-2">
1056
+ <button onClick={handleAdd} disabled={!id} className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded disabled:opacity-50">Add</button>
1057
+ <button onClick={() => setOpen(false)} className="text-[10px] px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded">Cancel</button>
1058
+ </div>
1059
+ </div>
1060
+ );
1061
+ }
1062
+
1063
+ function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
1064
+ const [agents, setAgents] = useState<AgentEntry[]>([]);
1065
+ const [loading, setLoading] = useState(true);
1066
+ const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
1067
+ const [showAdd, setShowAdd] = useState(false);
1068
+ const [newAgent, setNewAgent] = useState({ id: '', name: '', path: '', taskFlags: '', interactiveCmd: '', resumeFlag: '', outputFormat: 'text', models: { terminal: 'default', task: 'default', telegram: 'default', help: 'default', mobile: 'default' }, skipPermissionsFlag: '', requiresTTY: false });
1069
+
1070
+ // Fetch detected + configured agents
1071
+ useEffect(() => {
1072
+ (async () => {
1073
+ setLoading(true);
1074
+ try {
1075
+ const res = await fetch('/api/agents');
1076
+ const data = await res.json();
1077
+ const detected = (data.agents || []) as any[];
1078
+ const configured = settings.agents || {};
1079
+
1080
+ const merged: AgentEntry[] = [];
1081
+
1082
+ // Add agents from API (may be detected or configured-only)
1083
+ for (const a of detected) {
1084
+ const cfg = configured[a.id] || {};
1085
+ merged.push({
1086
+ id: a.id,
1087
+ name: cfg.name || a.name,
1088
+ path: cfg.path || a.path,
1089
+ enabled: cfg.enabled !== false,
1090
+ type: a.type || 'generic',
1091
+ taskFlags: cfg.taskFlags || (a.id === 'claude' ? '-p --verbose --output-format stream-json --dangerously-skip-permissions' : cfg.flags?.join(' ') || ''),
1092
+ interactiveCmd: cfg.interactiveCmd || a.path,
1093
+ resumeFlag: cfg.resumeFlag || (a.capabilities?.supportsResume ? '-c' : ''),
1094
+ outputFormat: cfg.outputFormat || (a.capabilities?.supportsStreamJson ? 'stream-json' : 'text'),
1095
+ models: cfg.models || { terminal: "default", task: "default", telegram: "default", help: "default", mobile: "default" },
1096
+ skipPermissionsFlag: cfg.skipPermissionsFlag || a.skipPermissionsFlag || "",
1097
+ requiresTTY: cfg.requiresTTY ?? a.capabilities?.requiresTTY ?? false,
1098
+ detected: a.detected !== false,
1099
+ });
1100
+ }
1101
+
1102
+ // Add configured but not detected agents
1103
+ for (const [id, cfg] of Object.entries(configured) as [string, any][]) {
1104
+ if (merged.find(a => a.id === id)) continue;
1105
+ merged.push({
1106
+ id,
1107
+ name: cfg.name || id,
1108
+ path: cfg.path || '',
1109
+ enabled: cfg.enabled !== false,
1110
+ type: 'generic',
1111
+ taskFlags: cfg.taskFlags || cfg.flags?.join(' ') || '',
1112
+ interactiveCmd: cfg.interactiveCmd || cfg.path || '',
1113
+ resumeFlag: cfg.resumeFlag || '',
1114
+ outputFormat: cfg.outputFormat || 'text',
1115
+ models: cfg.models || { terminal: "default", task: "default", telegram: "default", help: "default", mobile: "default" },
1116
+ skipPermissionsFlag: cfg.skipPermissionsFlag || '',
1117
+ requiresTTY: cfg.requiresTTY ?? false,
1118
+ detected: false,
1119
+ });
1120
+ }
1121
+
1122
+ setAgents(merged);
1123
+ } catch {}
1124
+ setLoading(false);
1125
+ })();
1126
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1127
+ }, []); // Only fetch once on mount
1128
+
1129
+ const defaultAgent = settings.defaultAgent || 'claude';
1130
+
1131
+ const saveAgentConfig = (updated: AgentEntry[]) => {
1132
+ // Start with existing config to preserve profile fields (base/env/model/type/provider/apiKey)
1133
+ const agentsCfg: Record<string, any> = { ...(settings.agents || {}) };
1134
+ for (const a of updated) {
1135
+ const existing = agentsCfg[a.id] || {};
1136
+ agentsCfg[a.id] = {
1137
+ ...existing, // preserve profile-specific fields
1138
+ name: a.name,
1139
+ path: a.path,
1140
+ enabled: a.enabled,
1141
+ taskFlags: a.taskFlags,
1142
+ interactiveCmd: a.interactiveCmd,
1143
+ resumeFlag: a.resumeFlag,
1144
+ outputFormat: a.outputFormat,
1145
+ models: a.models,
1146
+ skipPermissionsFlag: a.skipPermissionsFlag,
1147
+ requiresTTY: a.requiresTTY,
1148
+ };
1149
+ }
1150
+ // Keep claudePath in sync for backward compat
1151
+ const claude = updated.find(a => a.id === 'claude');
1152
+ setSettings({ ...settings, agents: agentsCfg, claudePath: claude?.path || settings.claudePath });
1153
+ };
1154
+
1155
+ const [agentsDirty, setAgentsDirty] = useState(false);
1156
+ const saveTimerRef = useRef<any>(null);
1157
+
1158
+ const debouncedSave = useCallback((updated: AgentEntry[]) => {
1159
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
1160
+ saveTimerRef.current = setTimeout(() => {
1161
+ saveAgentConfig(updated);
1162
+ setAgentsDirty(false);
1163
+ }, 1000); // save after 1s of no changes
1164
+ }, [saveAgentConfig]);
1165
+
1166
+ const updateAgent = (id: string, field: string, value: any) => {
1167
+ const updated = agents.map(a => a.id === id ? { ...a, [field]: value } : a);
1168
+ setAgents(updated);
1169
+ setAgentsDirty(true);
1170
+ debouncedSave(updated);
1171
+ };
1172
+
1173
+ const saveAgents = () => {
1174
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
1175
+ saveAgentConfig(agents);
1176
+ setAgentsDirty(false);
1177
+ };
1178
+
1179
+ const removeAgent = (id: string) => {
1180
+ if (!confirm(`Remove "${id}" agent?`)) return;
1181
+ const updated = agents.filter(a => a.id !== id);
1182
+ setAgents(updated);
1183
+ debouncedSave(updated);
1184
+ };
1185
+
1186
+ const addAgent = () => {
1187
+ if (!newAgent.id || !newAgent.path) return;
1188
+ const entry: AgentEntry = {
1189
+ ...newAgent,
1190
+ enabled: true,
1191
+ type: 'generic',
1192
+ detected: false,
1193
+ };
1194
+ const updated = [...agents, entry];
1195
+ setAgents(updated);
1196
+ debouncedSave(updated);
1197
+ setShowAdd(false);
1198
+ setNewAgent({ id: '', name: '', path: '', taskFlags: '', interactiveCmd: '', resumeFlag: '', outputFormat: 'text', models: { terminal: 'default', task: 'default', telegram: 'default', help: 'default', mobile: 'default' }, skipPermissionsFlag: '', requiresTTY: false });
1199
+ };
1200
+
1201
+ const inputClass = "w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]";
1202
+
1203
+ return (
1204
+ <div className="space-y-3">
1205
+ <div className="flex items-center gap-2">
1206
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">Agents</label>
1207
+ <button
1208
+ onClick={async () => {
1209
+ try {
1210
+ const res = await fetch('/api/agents');
1211
+ const data = await res.json();
1212
+ if (data.agents?.length) alert(`Detected: ${data.agents.map((a: any) => a.name).join(', ')}`);
1213
+ else alert('No agents detected');
1214
+ } catch { alert('Detection failed'); }
1215
+ }}
1216
+ className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white ml-auto"
1217
+ >Detect</button>
1218
+ <button
1219
+ onClick={() => setShowAdd(v => !v)}
1220
+ className="text-[9px] px-2 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
1221
+ >+ Add</button>
1222
+ {agentsDirty && (
1223
+ <button
1224
+ onClick={saveAgents}
1225
+ className="text-[9px] px-2 py-0.5 bg-[var(--accent)] text-white rounded"
1226
+ >Save Agents</button>
1227
+ )}
1228
+ </div>
1229
+
1230
+ {/* Default agent selector */}
1231
+ <div className="flex items-center gap-2">
1232
+ <span className="text-[10px] text-[var(--text-secondary)]">Default:</span>
1233
+ <select
1234
+ value={defaultAgent}
1235
+ onChange={e => setSettings({ ...settings, defaultAgent: e.target.value })}
1236
+ className="bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-xs text-[var(--text-primary)]"
1237
+ >
1238
+ {agents.filter(a => a.enabled).map(a => (
1239
+ <option key={a.id} value={a.id}>{a.name}</option>
1240
+ ))}
1241
+ </select>
1242
+ <span className="text-[9px] text-[var(--text-secondary)]">Used for Task, Terminal, Pipeline, Mobile, Help</span>
1243
+ </div>
1244
+
1245
+ {loading ? (
1246
+ <p className="text-[10px] text-[var(--text-secondary)]">Loading agents...</p>
1247
+ ) : agents.length === 0 ? (
1248
+ <p className="text-[10px] text-[var(--text-secondary)]">No agents detected. Click Detect or Add manually.</p>
1249
+ ) : (
1250
+ <div className="space-y-2">
1251
+ {agents.map(a => (
1252
+ <div key={a.id} className="border border-[var(--border)] rounded-lg overflow-hidden">
1253
+ {/* Agent header */}
1254
+ <div
1255
+ className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[var(--bg-tertiary)]"
1256
+ onClick={() => setExpandedAgent(expandedAgent === a.id ? null : a.id)}
1257
+ >
1258
+ <span className={`w-2 h-2 rounded-full shrink-0 ${
1259
+ !a.detected ? 'bg-gray-500' : a.id === defaultAgent ? 'bg-green-500' : 'bg-green-400/60'
1260
+ }`} title={!a.detected ? 'Not installed' : a.id === defaultAgent ? 'Default agent' : 'Installed'} />
1261
+ <span className={`text-xs font-medium ${!a.detected ? 'text-[var(--text-secondary)]' : 'text-[var(--text-primary)]'}`}>{a.name}</span>
1262
+ <span className="text-[9px] text-[var(--text-secondary)] font-mono">{a.id}</span>
1263
+ {a.id === defaultAgent && <span className="text-[8px] px-1 rounded bg-green-500/20 text-green-400">default</span>}
1264
+ {!a.detected && <span className="text-[8px] text-gray-500">not installed</span>}
1265
+ <label className="flex items-center gap-1 ml-auto text-[9px] text-[var(--text-secondary)]" onClick={e => e.stopPropagation()}>
1266
+ <input type="checkbox" checked={a.enabled} onChange={e => updateAgent(a.id, 'enabled', e.target.checked)} className="accent-[var(--accent)]" />
1267
+ Enabled
1268
+ </label>
1269
+ <span className="text-[10px] text-[var(--text-secondary)]">{expandedAgent === a.id ? '▾' : '▸'}</span>
1270
+ </div>
1271
+
1272
+ {/* Agent detail */}
1273
+ {expandedAgent === a.id && (
1274
+ <div className="px-3 py-2 border-t border-[var(--border)] space-y-2 bg-[var(--bg-secondary)]">
1275
+ <div className="flex gap-2">
1276
+ <div className="flex-1">
1277
+ <label className="text-[9px] text-[var(--text-secondary)]">Name</label>
1278
+ <input value={a.name} onChange={e => updateAgent(a.id, 'name', e.target.value)} className={inputClass} />
1279
+ </div>
1280
+ <div className="w-36">
1281
+ <label className="text-[9px] text-[var(--text-secondary)]">CLI Type</label>
1282
+ <select value={(settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic')}
1283
+ onChange={e => setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), cliType: e.target.value } } })}
1284
+ className={inputClass}>
1285
+ <option value="claude-code">Claude Code</option>
1286
+ <option value="codex">Codex</option>
1287
+ <option value="aider">Aider</option>
1288
+ <option value="generic">Generic</option>
1289
+ </select>
1290
+ </div>
1291
+ </div>
1292
+ <div>
1293
+ <label className="text-[9px] text-[var(--text-secondary)]">Binary Path</label>
1294
+ <input value={a.path} onChange={e => updateAgent(a.id, 'path', e.target.value)} placeholder="/usr/local/bin/agent" className={inputClass} />
1295
+ </div>
1296
+ <div>
1297
+ <label className="text-[9px] text-[var(--text-secondary)]">Task Flags <span className="text-[8px]">(non-interactive mode, e.g. -p --output-format json)</span></label>
1298
+ <input value={a.taskFlags} onChange={e => updateAgent(a.id, 'taskFlags', e.target.value)} placeholder="-p --verbose" className={inputClass} />
1299
+ </div>
1300
+ <div>
1301
+ <label className="text-[9px] text-[var(--text-secondary)]">Interactive Command <span className="text-[8px]">(terminal startup)</span></label>
1302
+ <input value={a.interactiveCmd} onChange={e => updateAgent(a.id, 'interactiveCmd', e.target.value)} placeholder="claude" className={inputClass} />
1303
+ </div>
1304
+ <div className="flex gap-3">
1305
+ <div className="flex-1">
1306
+ <label className="text-[9px] text-[var(--text-secondary)]">Resume Flag <span className="text-[8px]">(empty = no resume)</span></label>
1307
+ <input value={a.resumeFlag} onChange={e => updateAgent(a.id, 'resumeFlag', e.target.value)} placeholder="-c or --resume" className={inputClass} />
1308
+ </div>
1309
+ <div className="w-32">
1310
+ <label className="text-[9px] text-[var(--text-secondary)]">Output Format</label>
1311
+ <select value={a.outputFormat} onChange={e => updateAgent(a.id, 'outputFormat', e.target.value)} className={inputClass}>
1312
+ <option value="stream-json">stream-json</option>
1313
+ <option value="json">json</option>
1314
+ <option value="text">text</option>
1315
+ </select>
1316
+ </div>
1317
+ </div>
1318
+ {/* Per-scene model config */}
1319
+ <div>
1320
+ <label className="text-[9px] text-[var(--text-secondary)] mb-1 block">
1321
+ Models per scene <span className="text-[8px]">(type or pick from presets below)</span>
1322
+ </label>
1323
+ <div className="grid grid-cols-5 gap-1">
1324
+ {(['terminal', 'task', 'telegram', 'help', 'mobile'] as const).map(scene => (
1325
+ <div key={scene}>
1326
+ <label className="text-[8px] text-[var(--text-secondary)] capitalize">{scene}</label>
1327
+ <input
1328
+ value={a.models[scene]}
1329
+ onChange={e => {
1330
+ const updated = { ...a.models, [scene]: e.target.value };
1331
+ updateAgent(a.id, 'models', updated);
1332
+ }}
1333
+ placeholder="default"
1334
+ className="w-full px-1.5 py-0.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[9px] text-[var(--text-primary)] font-mono"
1335
+ />
1336
+ </div>
1337
+ ))}
1338
+ </div>
1339
+ {/* Preset models */}
1340
+ <div className="flex items-center gap-1 mt-1.5 flex-wrap">
1341
+ <span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
1342
+ {(a.id === 'claude'
1343
+ ? ['default', 'sonnet', 'opus', 'haiku', 'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5-20251001']
1344
+ : a.id === 'codex'
1345
+ ? ['default', 'o3-mini', 'o4-mini', 'gpt-4.1']
1346
+ : ['default']
1347
+ ).map(preset => (
1348
+ <button
1349
+ key={preset}
1350
+ onClick={() => navigator.clipboard.writeText(preset)}
1351
+ className="text-[8px] px-1 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
1352
+ title={`Click to copy "${preset}"`}
1353
+ >{preset}</button>
1354
+ ))}
1355
+ </div>
1356
+ </div>
1357
+ <div>
1358
+ <label className="text-[9px] text-[var(--text-secondary)]">Auto-approve flag <span className="text-[8px]">(empty = requires manual approval)</span></label>
1359
+ <input value={a.skipPermissionsFlag} onChange={e => updateAgent(a.id, 'skipPermissionsFlag', e.target.value)} placeholder="e.g. --dangerously-skip-permissions" className={inputClass} />
1360
+ <div className="flex gap-1 mt-1">
1361
+ {[
1362
+ { label: 'Claude', flag: '--dangerously-skip-permissions' },
1363
+ { label: 'Codex', flag: '--full-auto' },
1364
+ { label: 'Aider', flag: '--yes' },
1365
+ ].map(p => (
1366
+ <button key={p.label} onClick={() => updateAgent(a.id, 'skipPermissionsFlag', p.flag)}
1367
+ className="text-[8px] px-1 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
1368
+ >{p.label}: {p.flag}</button>
1369
+ ))}
1370
+ </div>
1371
+ </div>
1372
+ <label className="flex items-center gap-2 text-[9px] text-[var(--text-secondary)] cursor-pointer">
1373
+ <input type="checkbox" checked={a.requiresTTY} onChange={e => updateAgent(a.id, 'requiresTTY', e.target.checked)} className="accent-[var(--accent)]" />
1374
+ Requires terminal environment (TTY)
1375
+ <span className="text-[8px]">— enable for agents that need a terminal to run (e.g. Codex)</span>
1376
+ </label>
1377
+ {a.id !== 'claude' && (
1378
+ <button onClick={() => removeAgent(a.id)} className="text-[9px] text-red-400 hover:underline">Remove Agent</button>
1379
+ )}
1380
+
1381
+ {/* Profile selector */}
1382
+ <div>
1383
+ <label className="text-[9px] text-[var(--text-secondary)]">Profile <span className="text-[8px]">— select to override model, env vars, API endpoint</span></label>
1384
+ <select
1385
+ value={(settings.agents?.[a.id] as any)?.profile || ''}
1386
+ onChange={e => setSettings({ ...settings, agents: { ...settings.agents, [a.id]: { ...(settings.agents?.[a.id] || {}), profile: e.target.value || undefined } } })}
1387
+ className={inputClass}
1388
+ >
1389
+ <option value="">Default (no profile)</option>
1390
+ {Object.entries(settings.agents || {}).filter(([, cfg]: [string, any]) => cfg.base || cfg.type === 'profile').map(([pid, cfg]: [string, any]) => (
1391
+ <option key={pid} value={pid}>{cfg.name || pid}{cfg.model ? ` (${cfg.model})` : ''}</option>
1392
+ ))}
1393
+ </select>
1394
+ </div>
1395
+ </div>
1396
+ )}
1397
+ </div>
1398
+ ))}
1399
+ </div>
1400
+ )}
1401
+
1402
+ {/* Add agent form */}
1403
+ {showAdd && (
1404
+ <div className="border border-[var(--accent)]/30 rounded-lg p-3 space-y-2 bg-[var(--bg-secondary)]">
1405
+ <div className="text-[10px] text-[var(--text-primary)] font-semibold">Add Custom Agent</div>
1406
+ <div className="grid grid-cols-2 gap-2">
1407
+ <div>
1408
+ <label className="text-[9px] text-[var(--text-secondary)]">ID (unique)</label>
1409
+ <input value={newAgent.id} onChange={e => setNewAgent({ ...newAgent, id: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') })} placeholder="my-agent" className={inputClass} />
1410
+ </div>
1411
+ <div>
1412
+ <label className="text-[9px] text-[var(--text-secondary)]">Display Name</label>
1413
+ <input value={newAgent.name} onChange={e => setNewAgent({ ...newAgent, name: e.target.value })} placeholder="My Agent" className={inputClass} />
1414
+ </div>
1415
+ </div>
1416
+ <div>
1417
+ <label className="text-[9px] text-[var(--text-secondary)]">Binary Path</label>
1418
+ <input value={newAgent.path} onChange={e => setNewAgent({ ...newAgent, path: e.target.value })} placeholder="/usr/local/bin/my-agent" className={inputClass} />
1419
+ </div>
1420
+ <div>
1421
+ <label className="text-[9px] text-[var(--text-secondary)]">Task Flags (non-interactive)</label>
1422
+ <input value={newAgent.taskFlags} onChange={e => setNewAgent({ ...newAgent, taskFlags: e.target.value })} placeholder="--prompt" className={inputClass} />
1423
+ </div>
1424
+ <div className="flex gap-2">
1425
+ <button onClick={addAgent} disabled={!newAgent.id || !newAgent.path} className="text-[10px] px-3 py-1 bg-[var(--accent)] text-white rounded disabled:opacity-50">Add</button>
1426
+ <button onClick={() => setShowAdd(false)} className="text-[10px] px-3 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded">Cancel</button>
1427
+ </div>
1428
+ </div>
1429
+ )}
1430
+
1431
+ {/* ── Profiles Section ── */}
1432
+ <div className="mt-4 pt-3 border-t border-[var(--border)]">
1433
+ <div className="flex items-center gap-2 mb-2">
1434
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">Profiles</label>
1435
+ <span className="text-[8px] text-[var(--text-secondary)]">Shared across workspace and terminal — override model, env vars, API endpoint</span>
1436
+ </div>
1437
+
1438
+ {/* All profiles (CLI + API) */}
1439
+ {Object.entries(settings.agents || {}).filter(([, cfg]: [string, any]) => cfg.base || cfg.type === 'api').map(([id, cfg]: [string, any]) => (
1440
+ <ProfileRow key={id} id={id} cfg={cfg} inputClass={inputClass}
1441
+ onUpdate={(updated) => setSettings({ ...settings, agents: { ...settings.agents, [id]: updated } })}
1442
+ onDelete={() => {
1443
+ const updated = { ...settings.agents };
1444
+ delete updated[id];
1445
+ setSettings({ ...settings, agents: updated });
1446
+ }}
1447
+ />
1448
+ ))}
1449
+
1450
+ <div className="flex gap-2 mt-1">
1451
+ <AddProfileForm type="cli" baseAgents={agents.filter(a => !a.isProfile && a.detected)} onAdd={(id, cfg) => {
1452
+ setSettings({ ...settings, agents: { ...settings.agents, [id]: cfg } });
1453
+ }} />
1454
+ <AddProfileForm type="api" baseAgents={[]} onAdd={(id, cfg) => {
1455
+ setSettings({ ...settings, agents: { ...settings.agents, [id]: cfg } });
1456
+ }} />
1457
+ </div>
1458
+ </div>
1459
+
1460
+ {/* ── Providers Section ── */}
1461
+ <div className="mt-4 pt-3 border-t border-[var(--border)]">
1462
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase mb-2 block">API Providers</label>
1463
+ {['anthropic', 'google', 'openai', 'grok'].map(name => {
1464
+ const provider = settings.providers?.[name] || {};
1465
+ const secretKey = `providers.${name}.apiKey`;
1466
+ const hasKey = (provider.apiKey && provider.apiKey !== '••••••••') || settings._secretStatus?.[secretKey];
1467
+ return (
1468
+ <div key={name} className="flex items-center gap-2 px-2 py-1.5 mb-1 rounded" style={{ background: 'var(--bg-tertiary)' }}>
1469
+ <span className="text-[10px] text-[var(--text-primary)] w-20 font-semibold capitalize">{name}</span>
1470
+ <input
1471
+ type="password"
1472
+ placeholder="API Key"
1473
+ value={provider.apiKey || ''}
1474
+ onChange={e => setSettings({
1475
+ ...settings,
1476
+ providers: { ...settings.providers, [name]: { ...provider, apiKey: e.target.value } }
1477
+ })}
1478
+ className="flex-1 text-[9px] px-2 py-0.5 bg-[var(--bg-secondary)] border border-[var(--border)] rounded text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
1479
+ />
1480
+ <span className={`text-[8px] ${hasKey ? 'text-green-400' : 'text-gray-600'}`}>
1481
+ {hasKey ? '● set' : '○'}
1482
+ </span>
1483
+ </div>
1484
+ );
1485
+ })}
1486
+ </div>
1487
+ </div>
1488
+ );
1489
+ }
1490
+
1491
+ // ─── Telegram Agent Selector ──────────────────────────────
1492
+
1493
+ function TelegramAgentSelect({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
1494
+ const [agents, setAgents] = useState<{ id: string; name: string }[]>([]);
1495
+ useEffect(() => {
1496
+ fetch('/api/agents').then(r => r.json())
1497
+ .then(data => setAgents((data.agents || []).filter((a: any) => a.enabled)))
1498
+ .catch(() => {});
1499
+ }, []);
1500
+
1501
+ if (agents.length <= 1) return null;
1502
+
1503
+ return (
1504
+ <div className="flex items-center gap-2 mt-1">
1505
+ <span className="text-[9px] text-[var(--text-secondary)]">Default Agent:</span>
1506
+ <select
1507
+ value={settings.telegramAgent || ''}
1508
+ onChange={e => setSettings({ ...settings, telegramAgent: e.target.value })}
1509
+ className="bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-0.5 text-[10px] text-[var(--text-primary)]"
1510
+ >
1511
+ <option value="">Global default ({settings.defaultAgent || 'claude'})</option>
1512
+ {agents.map(a => (
1513
+ <option key={a.id} value={a.id}>{a.name}</option>
1514
+ ))}
1515
+ </select>
1516
+ <span className="text-[8px] text-[var(--text-secondary)]">Used for /task without @agent</span>
1517
+ </div>
1518
+ );
1519
+ }
1520
+
1521
+ // ─── Docs Agent Selector ──────────────────────────────
1522
+
1523
+ function DocsAgentSelect({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
1524
+ const [agents, setAgents] = useState<{ id: string; name: string }[]>([]);
1525
+ useEffect(() => {
1526
+ fetch('/api/agents').then(r => r.json())
1527
+ .then(data => setAgents((data.agents || []).filter((a: any) => a.enabled)))
1528
+ .catch(() => {});
1529
+ }, []);
1530
+
1531
+ if (agents.length <= 1) return null;
1532
+
1533
+ return (
1534
+ <div className="flex items-center gap-2 mt-1">
1535
+ <span className="text-[9px] text-[var(--text-secondary)]">Docs Agent:</span>
1536
+ <select
1537
+ value={settings.docsAgent || ''}
1538
+ onChange={e => setSettings({ ...settings, docsAgent: e.target.value })}
1539
+ className="bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-0.5 text-[10px] text-[var(--text-primary)]"
1540
+ >
1541
+ <option value="">Global default ({settings.defaultAgent || 'claude'})</option>
1542
+ {agents.map(a => (
1543
+ <option key={a.id} value={a.id}>{a.name}</option>
1544
+ ))}
1545
+ </select>
1546
+ </div>
1547
+ );
1548
+ }