@aion0/forge 0.5.21 → 0.5.22

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 (39) hide show
  1. package/.forge/agent-context.json +1 -1
  2. package/.forge/mcp.json +1 -1
  3. package/RELEASE_NOTES.md +31 -9
  4. package/app/api/plugins/route.ts +75 -0
  5. package/components/Dashboard.tsx +1 -0
  6. package/components/PipelineEditor.tsx +135 -9
  7. package/components/PluginsPanel.tsx +472 -0
  8. package/components/ProjectDetail.tsx +36 -98
  9. package/components/SessionView.tsx +4 -4
  10. package/components/SettingsModal.tsx +160 -66
  11. package/components/SkillsPanel.tsx +14 -5
  12. package/components/TerminalLauncher.tsx +398 -0
  13. package/components/WebTerminal.tsx +84 -84
  14. package/components/WorkspaceView.tsx +256 -76
  15. package/lib/agents/index.ts +7 -4
  16. package/lib/builtin-plugins/docker.yaml +70 -0
  17. package/lib/builtin-plugins/http.yaml +66 -0
  18. package/lib/builtin-plugins/jenkins.yaml +92 -0
  19. package/lib/builtin-plugins/llm-vision.yaml +85 -0
  20. package/lib/builtin-plugins/playwright.yaml +111 -0
  21. package/lib/builtin-plugins/shell-command.yaml +60 -0
  22. package/lib/builtin-plugins/slack.yaml +48 -0
  23. package/lib/builtin-plugins/webhook.yaml +56 -0
  24. package/lib/forge-mcp-server.ts +116 -2
  25. package/lib/pipeline.ts +62 -5
  26. package/lib/plugins/executor.ts +347 -0
  27. package/lib/plugins/registry.ts +228 -0
  28. package/lib/plugins/types.ts +103 -0
  29. package/lib/project-sessions.ts +7 -2
  30. package/lib/session-utils.ts +7 -3
  31. package/lib/terminal-standalone.ts +6 -34
  32. package/lib/workspace/agent-worker.ts +1 -1
  33. package/lib/workspace/orchestrator.ts +414 -136
  34. package/lib/workspace/presets.ts +5 -3
  35. package/lib/workspace/session-monitor.ts +14 -10
  36. package/lib/workspace/types.ts +3 -1
  37. package/lib/workspace-standalone.ts +38 -21
  38. package/package.json +1 -1
  39. package/qa/.forge/agent-context.json +1 -1
@@ -442,8 +442,7 @@ export default function SessionView({
442
442
  onClick={(e) => {
443
443
  e.stopPropagation();
444
444
  const pp = projects.find(p => p.name === project)?.path || '';
445
- if (pp && onOpenInTerminal) onOpenInTerminal(s.sessionId, pp);
446
- else if (pp) window.dispatchEvent(new CustomEvent('forge:open-terminal', { detail: { projectPath: pp, projectName: project, agentId: 'claude', resumeMode: true, sessionId: s.sessionId } }));
445
+ if (pp) window.dispatchEvent(new CustomEvent('forge:open-terminal', { detail: { projectPath: pp, projectName: project, agentId: 'claude', resumeMode: true, sessionId: s.sessionId } }));
447
446
  }}
448
447
  className="text-[8px] px-1 py-0.5 rounded bg-green-500/10 text-green-400 hover:bg-green-500/20"
449
448
  title="Open this session in terminal"
@@ -551,11 +550,11 @@ export default function SessionView({
551
550
  Monitor
552
551
  </button>
553
552
  )}
554
- {onOpenInTerminal && activeSessionId && (
553
+ {activeSessionId && (
555
554
  <button
556
555
  onClick={() => {
557
556
  const proj = projects.find(p => p.name === selectedProject);
558
- if (proj) onOpenInTerminal(activeSessionId!, proj.path);
557
+ if (proj) window.dispatchEvent(new CustomEvent('forge:open-terminal', { detail: { projectPath: proj.path, projectName: selectedProject, agentId: 'claude', resumeMode: true, sessionId: activeSessionId } }));
559
558
  }}
560
559
  className="text-[10px] px-2 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
561
560
  >
@@ -602,6 +601,7 @@ export default function SessionView({
602
601
  <div ref={bottomRef} />
603
602
  </div>
604
603
  </div>
604
+
605
605
  </div>
606
606
  );
607
607
  }
@@ -809,7 +809,7 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
809
809
  const isApi = cfg.type === 'api';
810
810
  const summary = isApi
811
811
  ? `API: ${cfg.provider || '?'} / ${cfg.model || '?'}`
812
- : `CLI: ${cfg.base || '?'} / ${cfg.model || cfg.models?.task || 'default'}`;
812
+ : `CLI: ${cfg.cliType || cfg.base || '?'} / ${cfg.model || cfg.models?.task || 'default'}`;
813
813
  const envStr = cfg.env ? Object.entries(cfg.env).map(([k, v]) => `${k}=${v}`).join('\n') : '';
814
814
 
815
815
  return (
@@ -831,7 +831,22 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
831
831
  </div>
832
832
  <div className="flex-1">
833
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} />
834
+ <input value={cfg.model || ''} onChange={e => onUpdate({ ...cfg, model: e.target.value })}
835
+ list={`profile-model-${id}`} className={inputClass} />
836
+ {(cfg.cliType === 'claude-code' || (!cfg.cliType && !cfg.base && !isApi)) && (
837
+ <datalist id={`profile-model-${id}`}>
838
+ <option value="claude-opus-4-6" />
839
+ <option value="claude-sonnet-4-6" />
840
+ <option value="claude-haiku-4-5-20251001" />
841
+ </datalist>
842
+ )}
843
+ {(cfg.cliType === 'codex' || cfg.base === 'codex') && (
844
+ <datalist id={`profile-model-${id}`}>
845
+ <option value="codex-mini" />
846
+ <option value="o4-mini" />
847
+ <option value="gpt-4o" />
848
+ </datalist>
849
+ )}
835
850
  </div>
836
851
  </div>
837
852
  {isApi ? (
@@ -854,7 +869,7 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
854
869
  <>
855
870
  <div>
856
871
  <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}>
872
+ <select value={cfg.cliType === 'claude-code' ? 'claude' : (cfg.cliType || cfg.base || 'claude')} onChange={e => onUpdate({ ...cfg, cliType: e.target.value === 'claude' ? 'claude-code' : e.target.value })} className={inputClass}>
858
873
  <option value="claude">Claude Code</option>
859
874
  <option value="codex">Codex</option>
860
875
  <option value="aider">Aider</option>
@@ -864,14 +879,15 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
864
879
  <div>
865
880
  <div className="flex items-center gap-2">
866
881
  <label className="text-[8px] text-[var(--text-secondary)]">Environment Variables (KEY=VALUE per line)</label>
867
- {cfg.base && (
882
+ {(cfg.cliType || cfg.base) && (
868
883
  <button onClick={() => {
869
884
  const templates: Record<string, string> = {
885
+ 'claude-code': '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',
870
886
  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
887
  codex: 'OPENAI_API_KEY=\nOPENAI_BASE_URL=',
872
888
  aider: 'ANTHROPIC_API_KEY=\nOPENAI_API_KEY=',
873
889
  };
874
- const tpl = templates[cfg.base!];
890
+ const tpl = templates[cfg.cliType || cfg.base!];
875
891
  if (tpl) {
876
892
  const env: Record<string, string> = {};
877
893
  for (const line of tpl.split('\n')) {
@@ -883,7 +899,7 @@ function ProfileRow({ id, cfg, inputClass, onUpdate, onDelete }: {
883
899
  onUpdate({ ...cfg, env: merged });
884
900
  }
885
901
  }} 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
902
+ Fill {cfg.cliType === 'claude-code' ? 'claude' : (cfg.cliType || cfg.base)} template
887
903
  </button>
888
904
  )}
889
905
  </div>
@@ -978,7 +994,7 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
978
994
  const handleAdd = () => {
979
995
  if (!id) return;
980
996
  if (type === 'cli') {
981
- onAdd(id, { base, cliType: base === 'claude' ? 'claude-code' : base, name: name || id, model: model || undefined, env: parseEnv() });
997
+ onAdd(id, { cliType: base === 'claude' ? 'claude-code' : base, name: name || id, model: model || undefined, env: parseEnv() });
982
998
  } else {
983
999
  onAdd(id, { type: 'api', name: name || id, provider, model: model || undefined, apiKey: apiKey || undefined });
984
1000
  }
@@ -1013,7 +1029,23 @@ function AddProfileForm({ type, baseAgents, onAdd }: {
1013
1029
  </div>
1014
1030
  <div className="flex-1">
1015
1031
  <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} />
1032
+ <input value={model} onChange={e => setModel(e.target.value)}
1033
+ placeholder={base === 'claude' ? 'claude-sonnet-4-6' : base === 'codex' ? 'codex-mini' : ''}
1034
+ list={`model-list-${base}`} className={inputClass} />
1035
+ {base === 'claude' && (
1036
+ <datalist id="model-list-claude">
1037
+ <option value="claude-opus-4-6" />
1038
+ <option value="claude-sonnet-4-6" />
1039
+ <option value="claude-haiku-4-5-20251001" />
1040
+ </datalist>
1041
+ )}
1042
+ {base === 'codex' && (
1043
+ <datalist id="model-list-codex">
1044
+ <option value="codex-mini" />
1045
+ <option value="o4-mini" />
1046
+ <option value="gpt-4o" />
1047
+ </datalist>
1048
+ )}
1017
1049
  </div>
1018
1050
  </div>
1019
1051
  <div>
@@ -1065,17 +1097,35 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1065
1097
  const [loading, setLoading] = useState(true);
1066
1098
  const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
1067
1099
  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 });
1100
+ const cliDefaults: Record<string, any> = {
1101
+ 'claude-code': { taskFlags: '-p --verbose --output-format stream-json --dangerously-skip-permissions', resumeFlag: '-c', outputFormat: 'stream-json', skipPermissionsFlag: '--dangerously-skip-permissions' },
1102
+ 'codex': { taskFlags: '', resumeFlag: '', outputFormat: 'text', skipPermissionsFlag: '--full-auto' },
1103
+ 'aider': { taskFlags: '--message', resumeFlag: '', outputFormat: 'text', skipPermissionsFlag: '--yes' },
1104
+ 'generic': { taskFlags: '', resumeFlag: '', outputFormat: 'text', skipPermissionsFlag: '' },
1105
+ };
1106
+ const makeNewAgent = (cliType = 'claude-code') => ({
1107
+ id: '', name: '', path: '', interactiveCmd: '',
1108
+ models: { terminal: 'default', task: 'default', telegram: 'default', help: 'default', mobile: 'default' },
1109
+ requiresTTY: false, cliType,
1110
+ ...cliDefaults[cliType],
1111
+ });
1112
+ const [newAgent, setNewAgent] = useState(makeNewAgent());
1069
1113
 
1070
1114
  // Fetch detected + configured agents
1071
1115
  useEffect(() => {
1072
1116
  (async () => {
1073
1117
  setLoading(true);
1074
1118
  try {
1075
- const res = await fetch('/api/agents');
1076
- const data = await res.json();
1119
+ // Fetch both agents and settings together to avoid race condition
1120
+ // (settings prop may not be loaded yet when this effect runs)
1121
+ const [agentsRes, settingsRes] = await Promise.all([
1122
+ fetch('/api/agents'),
1123
+ fetch('/api/settings'),
1124
+ ]);
1125
+ const data = await agentsRes.json();
1126
+ const settingsData = await settingsRes.json();
1077
1127
  const detected = (data.agents || []) as any[];
1078
- const configured = settings.agents || {};
1128
+ const configured = settingsData.agents || {};
1079
1129
 
1080
1130
  const merged: AgentEntry[] = [];
1081
1131
 
@@ -1084,16 +1134,16 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1084
1134
  const cfg = configured[a.id] || {};
1085
1135
  merged.push({
1086
1136
  id: a.id,
1087
- name: cfg.name || a.name,
1088
- path: cfg.path || a.path,
1137
+ name: cfg.name ?? a.name,
1138
+ path: cfg.path ?? a.path,
1089
1139
  enabled: cfg.enabled !== false,
1090
1140
  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 || "",
1141
+ taskFlags: cfg.taskFlags ?? (a.id === 'claude' ? '-p --verbose --output-format stream-json --dangerously-skip-permissions' : cfg.flags?.join(' ') ?? ''),
1142
+ interactiveCmd: cfg.interactiveCmd ?? a.path,
1143
+ resumeFlag: cfg.resumeFlag ?? (a.capabilities?.supportsResume ? '-c' : ''),
1144
+ outputFormat: cfg.outputFormat ?? (a.capabilities?.supportsStreamJson ? 'stream-json' : 'text'),
1145
+ models: cfg.models ?? { terminal: "default", task: "default", telegram: "default", help: "default", mobile: "default" },
1146
+ skipPermissionsFlag: cfg.skipPermissionsFlag ?? a.skipPermissionsFlag ?? "",
1097
1147
  requiresTTY: cfg.requiresTTY ?? a.capabilities?.requiresTTY ?? false,
1098
1148
  detected: a.detected !== false,
1099
1149
  });
@@ -1104,16 +1154,16 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1104
1154
  if (merged.find(a => a.id === id)) continue;
1105
1155
  merged.push({
1106
1156
  id,
1107
- name: cfg.name || id,
1108
- path: cfg.path || '',
1157
+ name: cfg.name ?? id,
1158
+ path: cfg.path ?? '',
1109
1159
  enabled: cfg.enabled !== false,
1110
1160
  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 || '',
1161
+ taskFlags: cfg.taskFlags ?? cfg.flags?.join(' ') ?? '',
1162
+ interactiveCmd: cfg.interactiveCmd ?? cfg.path ?? '',
1163
+ resumeFlag: cfg.resumeFlag ?? '',
1164
+ outputFormat: cfg.outputFormat ?? 'text',
1165
+ models: cfg.models ?? { terminal: "default", task: "default", telegram: "default", help: "default", mobile: "default" },
1166
+ skipPermissionsFlag: cfg.skipPermissionsFlag ?? '',
1117
1167
  requiresTTY: cfg.requiresTTY ?? false,
1118
1168
  detected: false,
1119
1169
  });
@@ -1129,27 +1179,29 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1129
1179
  const defaultAgent = settings.defaultAgent || 'claude';
1130
1180
 
1131
1181
  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 });
1182
+ // Use functional update to avoid stale closure — each call sees the latest settings
1183
+ setSettings((prev: any) => {
1184
+ const agentsCfg: Record<string, any> = { ...(prev.agents || {}) };
1185
+ for (const a of updated) {
1186
+ const existing = agentsCfg[a.id] || {};
1187
+ agentsCfg[a.id] = {
1188
+ ...existing, // preserve profile-specific fields
1189
+ name: a.name,
1190
+ path: a.path,
1191
+ enabled: a.enabled,
1192
+ taskFlags: a.taskFlags,
1193
+ interactiveCmd: a.interactiveCmd,
1194
+ resumeFlag: a.resumeFlag,
1195
+ outputFormat: a.outputFormat,
1196
+ models: a.models,
1197
+ skipPermissionsFlag: a.skipPermissionsFlag,
1198
+ requiresTTY: a.requiresTTY,
1199
+ cliType: (a as any).cliType || existing.cliType,
1200
+ };
1201
+ }
1202
+ const claude = updated.find(a => a.id === 'claude');
1203
+ return { ...prev, agents: agentsCfg, claudePath: claude?.path || prev.claudePath };
1204
+ });
1153
1205
  };
1154
1206
 
1155
1207
  const [agentsDirty, setAgentsDirty] = useState(false);
@@ -1166,8 +1218,8 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1166
1218
  const updateAgent = (id: string, field: string, value: any) => {
1167
1219
  const updated = agents.map(a => a.id === id ? { ...a, [field]: value } : a);
1168
1220
  setAgents(updated);
1169
- setAgentsDirty(true);
1170
- debouncedSave(updated);
1221
+ // Sync to settings immediately (no debounce) so global Save always has latest data
1222
+ saveAgentConfig(updated);
1171
1223
  };
1172
1224
 
1173
1225
  const saveAgents = () => {
@@ -1195,7 +1247,7 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1195
1247
  setAgents(updated);
1196
1248
  debouncedSave(updated);
1197
1249
  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 });
1250
+ setNewAgent(makeNewAgent());
1199
1251
  };
1200
1252
 
1201
1253
  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)]";
@@ -1216,7 +1268,12 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1216
1268
  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
1269
  >Detect</button>
1218
1270
  <button
1219
- onClick={() => setShowAdd(v => !v)}
1271
+ onClick={() => {
1272
+ // Auto-fill path from detected claude agent when opening Add form
1273
+ const claude = agents.find(a => a.id === 'claude');
1274
+ if (claude?.path && !newAgent.path) setNewAgent((prev: any) => ({ ...prev, path: claude.path, interactiveCmd: claude.path }));
1275
+ setShowAdd(v => !v);
1276
+ }}
1220
1277
  className="text-[9px] px-2 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:text-[var(--text-primary)]"
1221
1278
  >+ Add</button>
1222
1279
  {agentsDirty && (
@@ -1339,12 +1396,12 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1339
1396
  {/* Preset models */}
1340
1397
  <div className="flex items-center gap-1 mt-1.5 flex-wrap">
1341
1398
  <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 => (
1399
+ {((() => {
1400
+ const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
1401
+ if (ct === 'claude-code') return ['default', 'sonnet', 'opus', 'haiku', 'claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5-20251001'];
1402
+ if (ct === 'codex') return ['default', 'o3-mini', 'o4-mini', 'gpt-4.1'];
1403
+ return ['default'];
1404
+ })()).map(preset => (
1348
1405
  <button
1349
1406
  key={preset}
1350
1407
  onClick={() => navigator.clipboard.writeText(preset)}
@@ -1403,7 +1460,7 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1403
1460
  {showAdd && (
1404
1461
  <div className="border border-[var(--accent)]/30 rounded-lg p-3 space-y-2 bg-[var(--bg-secondary)]">
1405
1462
  <div className="text-[10px] text-[var(--text-primary)] font-semibold">Add Custom Agent</div>
1406
- <div className="grid grid-cols-2 gap-2">
1463
+ <div className="grid grid-cols-3 gap-2">
1407
1464
  <div>
1408
1465
  <label className="text-[9px] text-[var(--text-secondary)]">ID (unique)</label>
1409
1466
  <input value={newAgent.id} onChange={e => setNewAgent({ ...newAgent, id: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') })} placeholder="my-agent" className={inputClass} />
@@ -1412,14 +1469,51 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
1412
1469
  <label className="text-[9px] text-[var(--text-secondary)]">Display Name</label>
1413
1470
  <input value={newAgent.name} onChange={e => setNewAgent({ ...newAgent, name: e.target.value })} placeholder="My Agent" className={inputClass} />
1414
1471
  </div>
1472
+ <div>
1473
+ <label className="text-[9px] text-[var(--text-secondary)]">CLI Type</label>
1474
+ <select value={newAgent.cliType} onChange={e => {
1475
+ const ct = e.target.value;
1476
+ // Auto-fill path from detected agent if available
1477
+ const baseId = ct === 'claude-code' ? 'claude' : ct;
1478
+ const detected = agents.find(a => a.id === baseId);
1479
+ setNewAgent({ ...newAgent, cliType: ct, ...(cliDefaults[ct] || {}), path: detected?.path || newAgent.path, interactiveCmd: detected?.path || newAgent.interactiveCmd });
1480
+ }} className={inputClass}>
1481
+ <option value="claude-code">Claude Code</option>
1482
+ <option value="codex">Codex</option>
1483
+ <option value="aider">Aider</option>
1484
+ <option value="generic">Generic</option>
1485
+ </select>
1486
+ </div>
1415
1487
  </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} />
1488
+ <div className="grid grid-cols-2 gap-2">
1489
+ <div>
1490
+ <label className="text-[9px] text-[var(--text-secondary)]">Binary Path</label>
1491
+ <input value={newAgent.path} onChange={e => setNewAgent({ ...newAgent, path: e.target.value })}
1492
+ placeholder={newAgent.cliType === 'claude-code' ? 'claude' : newAgent.cliType === 'codex' ? 'codex' : newAgent.cliType === 'aider' ? 'aider' : '/usr/local/bin/agent'}
1493
+ className={inputClass} />
1494
+ </div>
1495
+ <div>
1496
+ <label className="text-[9px] text-[var(--text-secondary)]">Task Flags (non-interactive)</label>
1497
+ <input value={newAgent.taskFlags} onChange={e => setNewAgent({ ...newAgent, taskFlags: e.target.value })} placeholder="--prompt" className={inputClass} />
1498
+ </div>
1419
1499
  </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} />
1500
+ <div className="grid grid-cols-3 gap-2">
1501
+ <div>
1502
+ <label className="text-[9px] text-[var(--text-secondary)]">Resume Flag</label>
1503
+ <input value={newAgent.resumeFlag} onChange={e => setNewAgent({ ...newAgent, resumeFlag: e.target.value })} placeholder="-c or --resume" className={inputClass} />
1504
+ </div>
1505
+ <div>
1506
+ <label className="text-[9px] text-[var(--text-secondary)]">Output Format</label>
1507
+ <select value={newAgent.outputFormat} onChange={e => setNewAgent({ ...newAgent, outputFormat: e.target.value })} className={inputClass}>
1508
+ <option value="stream-json">stream-json</option>
1509
+ <option value="json">json</option>
1510
+ <option value="text">text</option>
1511
+ </select>
1512
+ </div>
1513
+ <div>
1514
+ <label className="text-[9px] text-[var(--text-secondary)]">Skip Permissions Flag</label>
1515
+ <input value={newAgent.skipPermissionsFlag} onChange={e => setNewAgent({ ...newAgent, skipPermissionsFlag: e.target.value })} placeholder="--dangerously-skip-permissions" className={inputClass} />
1516
+ </div>
1423
1517
  </div>
1424
1518
  <div className="flex gap-2">
1425
1519
  <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>
@@ -1,8 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
4
4
  import { useSidebarResize } from '@/hooks/useSidebarResize';
5
5
 
6
+ const PluginsPanel = lazy(() => import('./PluginsPanel'));
7
+
6
8
  type ItemType = 'skill' | 'command';
7
9
 
8
10
  interface Skill {
@@ -131,7 +133,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
131
133
  const [syncing, setSyncing] = useState(false);
132
134
  const [loading, setLoading] = useState(true);
133
135
  const [installTarget, setInstallTarget] = useState<{ skill: string; show: boolean }>({ skill: '', show: false });
134
- const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules'>('all');
136
+ const [typeFilter, setTypeFilter] = useState<'all' | 'skill' | 'command' | 'local' | 'rules' | 'plugins'>('all');
135
137
  const [localItems, setLocalItems] = useState<{ name: string; type: string; scope: string; fileCount: number; projectPath?: string }[]>([]);
136
138
  // Rules (CLAUDE.md templates)
137
139
  const [rulesTemplates, setRulesTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; isDefault: boolean; content: string }[]>([]);
@@ -363,7 +365,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
363
365
  <div className="flex items-center gap-2">
364
366
  <span className="text-xs font-semibold text-[var(--text-primary)]">Marketplace</span>
365
367
  <div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
366
- {([['all', `All (${skills.length})`], ['skill', `Skills (${skillCount})`], ['command', `Commands (${commandCount})`], ['local', `Local (${localCount})`], ['rules', 'Rules']] as const).map(([value, label]) => (
368
+ {([['all', `All (${skills.length})`], ['skill', `Skills (${skillCount})`], ['command', `Commands (${commandCount})`], ['local', `Local (${localCount})`], ['rules', 'Rules'], ['plugins', 'Plugins']] as const).map(([value, label]) => (
367
369
  <button
368
370
  key={value}
369
371
  onClick={() => setTypeFilter(value)}
@@ -388,7 +390,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
388
390
  </button>
389
391
  </div>
390
392
  {/* Search — hide on rules tab */}
391
- {typeFilter !== 'rules' && <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0">
393
+ {typeFilter !== 'rules' && typeFilter !== 'plugins' && <div className="px-3 py-1.5 border-b border-[var(--border)] shrink-0">
392
394
  <input
393
395
  type="text"
394
396
  value={searchQuery}
@@ -398,7 +400,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
398
400
  />
399
401
  </div>}
400
402
 
401
- {typeFilter === 'rules' ? null : skills.length === 0 ? (
403
+ {typeFilter === 'rules' || typeFilter === 'plugins' ? null : skills.length === 0 ? (
402
404
  <div className="flex-1 flex flex-col items-center justify-center gap-2 text-[var(--text-secondary)]">
403
405
  <p className="text-xs">No skills yet</p>
404
406
  <button onClick={sync} className="text-xs px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90">
@@ -955,6 +957,13 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
955
957
  </div>
956
958
  </div>
957
959
  )}
960
+
961
+ {/* Plugins — full-page view */}
962
+ {typeFilter === 'plugins' && (
963
+ <Suspense fallback={<div className="p-4 text-xs text-[var(--text-secondary)]">Loading...</div>}>
964
+ <PluginsPanel />
965
+ </Suspense>
966
+ )}
958
967
  </div>
959
968
  );
960
969
  }