@aion0/forge 0.4.15 → 0.5.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.
- package/CLAUDE.md +1 -1
- package/README.md +2 -2
- package/RELEASE_NOTES.md +170 -13
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +23 -4
- package/check-forge-status.sh +9 -0
- package/cli/mw.ts +2 -2
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +12 -4
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +13 -8
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +34 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +259 -45
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2224 -0
- package/docs/LOCAL-DEPLOY.md +15 -15
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/cloudflared.ts +1 -1
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +8 -2
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +95 -6
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +204 -0
- package/lib/help-docs/CLAUDE.md +5 -2
- package/lib/init.ts +62 -12
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/telegram-standalone.ts +1 -1
- package/lib/terminal-server.ts +2 -2
- package/lib/terminal-standalone.ts +1 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1804 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +790 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +5 -2
- package/src/config/index.ts +13 -2
- package/src/core/db/database.ts +1 -0
- package/start.sh +10 -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={
|
|
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
|
-
{/*
|
|
411
|
-
<
|
|
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
|
+
}
|