@aion0/forge 0.9.11 → 0.9.13
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/RELEASE_NOTES.md +15 -4
- package/app/api/prompts/[name]/route.ts +37 -0
- package/app/api/prompts/route.ts +35 -0
- package/app/api/schedules/extract/route.ts +184 -0
- package/app/api/schedules/route.ts +14 -37
- package/components/ScheduleCreateModal.tsx +237 -537
- package/components/ScheduleQuickCreate.tsx +404 -0
- package/components/SchedulesView.tsx +18 -6
- package/components/SettingsModal.tsx +43 -4
- package/lib/forge-mcp-server.ts +84 -0
- package/lib/init.ts +7 -0
- package/lib/projects.ts +67 -2
- package/lib/prompts/store.ts +142 -0
- package/lib/prompts/types.ts +53 -0
- package/lib/schedules/scheduler.ts +51 -143
- package/lib/schedules/store.ts +6 -15
- package/lib/schedules/types.ts +10 -14
- package/package.json +1 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ScheduleQuickCreate — V3 D entry point (#266).
|
|
5
|
+
*
|
|
6
|
+
* Two stages:
|
|
7
|
+
* 1. Natural-language textarea → POST /api/schedules/extract
|
|
8
|
+
* 2. ScheduleConfirmCard renders the parsed spec, lets user adjust
|
|
9
|
+
* fields inline, then POSTs to /api/prompts + /api/schedules.
|
|
10
|
+
*
|
|
11
|
+
* Always half-automatic — Forge never auto-creates the schedule. The
|
|
12
|
+
* user must Confirm. Low confidence (< 0.7) or non-empty `missing[]`
|
|
13
|
+
* shows a banner asking them to verify before saving.
|
|
14
|
+
*
|
|
15
|
+
* Chat-thread integration (extractor invoked mid-chat with a Confirm Card
|
|
16
|
+
* inline) is a follow-up — same Card component will be reused there.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useEffect, useState } from 'react';
|
|
20
|
+
|
|
21
|
+
interface Extracted {
|
|
22
|
+
name: string;
|
|
23
|
+
prompt_name: string;
|
|
24
|
+
prompt: string;
|
|
25
|
+
agent: string | null;
|
|
26
|
+
skills: string[];
|
|
27
|
+
project: string | null;
|
|
28
|
+
trigger: {
|
|
29
|
+
kind: 'period' | 'cron' | 'once' | 'manual';
|
|
30
|
+
interval_minutes: number | null;
|
|
31
|
+
cron: string | null;
|
|
32
|
+
at_iso: string | null;
|
|
33
|
+
};
|
|
34
|
+
action: {
|
|
35
|
+
kind: 'none' | 'chat' | 'email' | 'telegram';
|
|
36
|
+
config: Record<string, unknown>;
|
|
37
|
+
};
|
|
38
|
+
confidence: number;
|
|
39
|
+
missing: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AgentInfo { id: string; label?: string; backendType?: string }
|
|
43
|
+
interface ProjectInfo { name: string; path: string }
|
|
44
|
+
|
|
45
|
+
interface Props {
|
|
46
|
+
onClose: () => void;
|
|
47
|
+
onCreated: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function ScheduleQuickCreate({ onClose, onCreated }: Props) {
|
|
51
|
+
const [text, setText] = useState('');
|
|
52
|
+
const [extracting, setExtracting] = useState(false);
|
|
53
|
+
const [extracted, setExtracted] = useState<Extracted | null>(null);
|
|
54
|
+
const [err, setErr] = useState('');
|
|
55
|
+
|
|
56
|
+
async function extract() {
|
|
57
|
+
setErr('');
|
|
58
|
+
setExtracting(true);
|
|
59
|
+
try {
|
|
60
|
+
const r = await fetch('/api/schedules/extract', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'content-type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ text: text.trim() }),
|
|
64
|
+
});
|
|
65
|
+
const j = await r.json();
|
|
66
|
+
if (!r.ok || !j.ok) {
|
|
67
|
+
setErr(j.error || `HTTP ${r.status}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
setExtracted(j.extracted as Extracted);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
73
|
+
} finally {
|
|
74
|
+
setExtracting(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[10vh]">
|
|
80
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded shadow-2xl w-[680px] max-w-[95vw] max-h-[85vh] flex flex-col">
|
|
81
|
+
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center">
|
|
82
|
+
<h2 className="text-[13px] font-semibold flex-1">✨ Quick create schedule</h2>
|
|
83
|
+
<button onClick={onClose} className="text-[14px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] w-6 h-6">✕</button>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div className="flex-1 overflow-auto p-4">
|
|
87
|
+
{!extracted && (
|
|
88
|
+
<>
|
|
89
|
+
<div className="text-[11px] text-[var(--text-secondary)] mb-2">
|
|
90
|
+
Describe what you want Forge to run on a schedule. Be specific about
|
|
91
|
+
<em> when</em>, <em>what</em>, and <em>where to send the result</em>.
|
|
92
|
+
Forge will parse it into a draft you can review and tweak before saving.
|
|
93
|
+
</div>
|
|
94
|
+
<textarea
|
|
95
|
+
value={text}
|
|
96
|
+
onChange={(e) => setText(e.target.value)}
|
|
97
|
+
rows={6}
|
|
98
|
+
placeholder="e.g. 每天上午 9 点查一下我今天分到的 mantis bug,发我 Telegram"
|
|
99
|
+
className="w-full text-[12px] font-mono px-3 py-2 border border-[var(--border)] rounded bg-[var(--bg-secondary)] resize-y"
|
|
100
|
+
/>
|
|
101
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-1">
|
|
102
|
+
Your wording stays verbatim — Forge does NOT rewrite it.
|
|
103
|
+
</div>
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{extracted && (
|
|
108
|
+
<ScheduleConfirmCard
|
|
109
|
+
initial={extracted}
|
|
110
|
+
originalText={text}
|
|
111
|
+
onCancel={() => setExtracted(null)}
|
|
112
|
+
onCreated={onCreated}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2 mt-3">{err}</div>}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{!extracted && (
|
|
120
|
+
<div className="px-4 py-3 border-t border-[var(--border)] flex items-center justify-between">
|
|
121
|
+
<button onClick={onClose} className="text-[11px] px-3 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Cancel</button>
|
|
122
|
+
<button
|
|
123
|
+
onClick={() => void extract()}
|
|
124
|
+
disabled={!text.trim() || extracting}
|
|
125
|
+
className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold disabled:opacity-50"
|
|
126
|
+
>{extracting ? 'Parsing…' : 'Parse →'}</button>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ScheduleConfirmCard({
|
|
135
|
+
initial, originalText, onCancel, onCreated,
|
|
136
|
+
}: {
|
|
137
|
+
initial: Extracted;
|
|
138
|
+
originalText: string;
|
|
139
|
+
onCancel: () => void;
|
|
140
|
+
onCreated: () => void;
|
|
141
|
+
}) {
|
|
142
|
+
const [name, setName] = useState(initial.name || initial.prompt_name || 'untitled');
|
|
143
|
+
const [promptText, setPromptText] = useState(initial.prompt);
|
|
144
|
+
const [agent, setAgent] = useState(initial.agent || '');
|
|
145
|
+
const [skills, setSkills] = useState<string[]>(initial.skills);
|
|
146
|
+
const [skillsRaw, setSkillsRaw] = useState(initial.skills.join(', '));
|
|
147
|
+
const [project, setProject] = useState(initial.project || '');
|
|
148
|
+
const [triggerKind, setTriggerKind] = useState(initial.trigger.kind);
|
|
149
|
+
const [interval, setInterval] = useState(initial.trigger.interval_minutes ?? 30);
|
|
150
|
+
const [cron, setCron] = useState(initial.trigger.cron ?? '');
|
|
151
|
+
const [atIso, setAtIso] = useState(initial.trigger.at_iso ?? '');
|
|
152
|
+
const [actionKind, setActionKind] = useState(initial.action.kind);
|
|
153
|
+
const [actionConfigJson, setActionConfigJson] = useState(
|
|
154
|
+
JSON.stringify(initial.action.config ?? {}, null, 2)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
158
|
+
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
159
|
+
const [submitting, setSubmitting] = useState(false);
|
|
160
|
+
const [err, setErr] = useState('');
|
|
161
|
+
|
|
162
|
+
const lowConfidence = initial.confidence < 0.7 || initial.missing.length > 0;
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
(async () => {
|
|
166
|
+
try {
|
|
167
|
+
const [skR, agR] = await Promise.all([
|
|
168
|
+
fetch('/api/skills').then((r) => r.json()),
|
|
169
|
+
fetch('/api/agents?include=all').then((r) => r.json()).catch(() => ({ agents: [] })),
|
|
170
|
+
]);
|
|
171
|
+
if (Array.isArray(skR?.projects)) setProjects(skR.projects);
|
|
172
|
+
if (Array.isArray(agR?.agents)) setAgents(agR.agents);
|
|
173
|
+
} catch {/* ignore — fields fall back to free text */}
|
|
174
|
+
})();
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
async function submit() {
|
|
178
|
+
setErr('');
|
|
179
|
+
setSubmitting(true);
|
|
180
|
+
try {
|
|
181
|
+
const cleanedSkills = skillsRaw
|
|
182
|
+
.split(/[,\s]+/)
|
|
183
|
+
.map((s) => s.trim())
|
|
184
|
+
.filter(Boolean);
|
|
185
|
+
// 1. Create prompts/<name>.yaml. Auto-suffix on 409 (same logic as
|
|
186
|
+
// ScheduleCreateModal — keeps quick-create idempotent across retries).
|
|
187
|
+
const base = slugifyName(name) || initial.prompt_name || `prompt-${Date.now().toString(36).slice(-6)}`;
|
|
188
|
+
let promptNameToUse: string | null = null;
|
|
189
|
+
let candidate = base;
|
|
190
|
+
for (let i = 2; i <= 50; i++) {
|
|
191
|
+
const r = await fetch('/api/prompts', {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'content-type': 'application/json' },
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
name: candidate,
|
|
196
|
+
prompt: promptText,
|
|
197
|
+
executor: { agent: agent || null, skills: cleanedSkills },
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
if (r.ok) { promptNameToUse = candidate; break; }
|
|
201
|
+
if (r.status === 409) { candidate = `${base}-${i}`; continue; }
|
|
202
|
+
const j = await r.json().catch(() => ({}));
|
|
203
|
+
setErr(j.error || `prompt save failed (${r.status})`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!promptNameToUse) {
|
|
207
|
+
setErr(`couldn't find a free prompt name starting from "${base}"`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 2. Create the schedule
|
|
212
|
+
let actionConfig: Record<string, unknown>;
|
|
213
|
+
try {
|
|
214
|
+
actionConfig = actionConfigJson.trim() ? JSON.parse(actionConfigJson) : {};
|
|
215
|
+
} catch (e) {
|
|
216
|
+
setErr(`action_config is not valid JSON: ${(e as Error).message}`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const body: Record<string, unknown> = {
|
|
220
|
+
name,
|
|
221
|
+
body_kind: 'prompt',
|
|
222
|
+
body_ref: promptNameToUse,
|
|
223
|
+
input: { project },
|
|
224
|
+
enabled: true,
|
|
225
|
+
schedule_kind: triggerKind,
|
|
226
|
+
schedule_interval_minutes: triggerKind === 'period' ? interval : null,
|
|
227
|
+
schedule_cron: triggerKind === 'cron' ? cron.trim() : null,
|
|
228
|
+
schedule_at: triggerKind === 'once' && atIso ? new Date(atIso).toISOString() : null,
|
|
229
|
+
action_kind: actionKind,
|
|
230
|
+
action_config: actionConfig,
|
|
231
|
+
};
|
|
232
|
+
const r = await fetch('/api/schedules', {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'content-type': 'application/json' },
|
|
235
|
+
body: JSON.stringify(body),
|
|
236
|
+
});
|
|
237
|
+
if (!r.ok) {
|
|
238
|
+
const j = await r.json().catch(() => ({}));
|
|
239
|
+
setErr(j.error || `schedule create failed (${r.status})`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
onCreated();
|
|
243
|
+
} catch (e) {
|
|
244
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
245
|
+
} finally {
|
|
246
|
+
setSubmitting(false);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div className="space-y-3">
|
|
252
|
+
<div className="text-[11px] text-[var(--text-secondary)]">
|
|
253
|
+
Forge parsed your request below. Review, tweak anything, then Create.
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{lowConfidence && (
|
|
257
|
+
<div className="text-[11px] text-[var(--yellow)] bg-[var(--yellow)]/10 rounded p-2 border border-[var(--yellow)]/30">
|
|
258
|
+
⚠ confidence {initial.confidence.toFixed(2)}
|
|
259
|
+
{initial.missing.length > 0 && <> · missing: <code className="font-mono">{initial.missing.join(', ')}</code></>}
|
|
260
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-0.5">Verify the fields before saving.</div>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
<Field label="Schedule name">
|
|
265
|
+
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
|
|
266
|
+
</Field>
|
|
267
|
+
|
|
268
|
+
<Field label="Prompt (verbatim)" hint="The agent will receive this exact text. Tweak if Forge mis-quoted.">
|
|
269
|
+
<textarea
|
|
270
|
+
value={promptText}
|
|
271
|
+
onChange={(e) => setPromptText(e.target.value)}
|
|
272
|
+
rows={5}
|
|
273
|
+
className={inputCls + ' font-mono'}
|
|
274
|
+
/>
|
|
275
|
+
</Field>
|
|
276
|
+
|
|
277
|
+
<div className="grid grid-cols-2 gap-3">
|
|
278
|
+
<Field label="Project" hint="Leave on scratch for prompts that just call connectors.">
|
|
279
|
+
<select value={project} onChange={(e) => setProject(e.target.value)} className={inputCls}>
|
|
280
|
+
<option value="">— scratch (default) —</option>
|
|
281
|
+
{projects.filter((p) => p.name !== 'scratch').map((p) => <option key={p.name} value={p.name}>{p.name}</option>)}
|
|
282
|
+
{project && project !== 'scratch' && !projects.some((p) => p.name === project) && (
|
|
283
|
+
<option value={project}>{project} (not in projects list)</option>
|
|
284
|
+
)}
|
|
285
|
+
</select>
|
|
286
|
+
</Field>
|
|
287
|
+
<Field label="Agent">
|
|
288
|
+
<select value={agent} onChange={(e) => setAgent(e.target.value)} className={inputCls}>
|
|
289
|
+
<option value="">— system default —</option>
|
|
290
|
+
{agents.map((a) => <option key={a.id} value={a.id}>{a.label || a.id}</option>)}
|
|
291
|
+
</select>
|
|
292
|
+
</Field>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<Field label="Skills" hint="Comma-separated. ai-orchestration is the default V3 skill (lets the agent call connectors).">
|
|
296
|
+
<input value={skillsRaw} onChange={(e) => setSkillsRaw(e.target.value)} className={inputCls + ' font-mono'} />
|
|
297
|
+
</Field>
|
|
298
|
+
|
|
299
|
+
<Field label="Trigger">
|
|
300
|
+
<div className="flex gap-2 items-baseline">
|
|
301
|
+
<select
|
|
302
|
+
value={triggerKind}
|
|
303
|
+
onChange={(e) => setTriggerKind(e.target.value as Extracted['trigger']['kind'])}
|
|
304
|
+
className={inputCls + ' w-32'}
|
|
305
|
+
>
|
|
306
|
+
<option value="period">period</option>
|
|
307
|
+
<option value="cron">cron</option>
|
|
308
|
+
<option value="once">once</option>
|
|
309
|
+
<option value="manual">manual</option>
|
|
310
|
+
</select>
|
|
311
|
+
{triggerKind === 'period' && (
|
|
312
|
+
<>
|
|
313
|
+
<input
|
|
314
|
+
type="number" min={1}
|
|
315
|
+
value={interval}
|
|
316
|
+
onChange={(e) => setInterval(Number(e.target.value) || 1)}
|
|
317
|
+
className={inputCls + ' w-24'}
|
|
318
|
+
/>
|
|
319
|
+
<span className="text-[10px] text-[var(--text-secondary)]">minutes</span>
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
{triggerKind === 'cron' && (
|
|
323
|
+
<input value={cron} onChange={(e) => setCron(e.target.value)} placeholder="e.g. 0 9 * * *"
|
|
324
|
+
className={inputCls + ' font-mono flex-1'} />
|
|
325
|
+
)}
|
|
326
|
+
{triggerKind === 'once' && (
|
|
327
|
+
<input
|
|
328
|
+
type="datetime-local"
|
|
329
|
+
value={atIso ? new Date(atIso).toISOString().slice(0, 16) : ''}
|
|
330
|
+
onChange={(e) => setAtIso(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
|
331
|
+
className={inputCls + ' flex-1'}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
</Field>
|
|
336
|
+
|
|
337
|
+
<Field label="Action">
|
|
338
|
+
<div className="flex gap-2 items-start">
|
|
339
|
+
<select
|
|
340
|
+
value={actionKind}
|
|
341
|
+
onChange={(e) => setActionKind(e.target.value as Extracted['action']['kind'])}
|
|
342
|
+
className={inputCls + ' w-32'}
|
|
343
|
+
>
|
|
344
|
+
<option value="none">none</option>
|
|
345
|
+
<option value="chat">chat</option>
|
|
346
|
+
<option value="email">email</option>
|
|
347
|
+
<option value="telegram">telegram</option>
|
|
348
|
+
</select>
|
|
349
|
+
{actionKind !== 'none' && (
|
|
350
|
+
<textarea
|
|
351
|
+
value={actionConfigJson}
|
|
352
|
+
onChange={(e) => setActionConfigJson(e.target.value)}
|
|
353
|
+
rows={3}
|
|
354
|
+
placeholder='{ "chat_id": "..." }'
|
|
355
|
+
className={inputCls + ' font-mono flex-1'}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
{actionKind !== 'none' && (
|
|
360
|
+
<div className="text-[10px] text-[var(--text-secondary)] mt-1">
|
|
361
|
+
JSON object; see Schedules help doc for the per-action shape. Chat needs <code className="font-mono">session_id</code>, email needs <code className="font-mono">to</code>, telegram needs <code className="font-mono">chat_id</code> (or default subscriber).
|
|
362
|
+
</div>
|
|
363
|
+
)}
|
|
364
|
+
</Field>
|
|
365
|
+
|
|
366
|
+
{err && <div className="text-[11px] text-[var(--red)] bg-[var(--red)]/10 rounded p-2">{err}</div>}
|
|
367
|
+
|
|
368
|
+
<details className="text-[10px] text-[var(--text-secondary)]">
|
|
369
|
+
<summary className="cursor-pointer">Original text</summary>
|
|
370
|
+
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{originalText}</pre>
|
|
371
|
+
</details>
|
|
372
|
+
|
|
373
|
+
<div className="flex justify-between pt-2 border-t border-[var(--border)]">
|
|
374
|
+
<button onClick={onCancel} className="text-[11px] px-3 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">← Re-parse</button>
|
|
375
|
+
<button
|
|
376
|
+
onClick={() => void submit()}
|
|
377
|
+
disabled={submitting || !promptText.trim() || !name.trim() || (triggerKind === 'cron' && !cron.trim()) || (triggerKind === 'once' && !atIso)}
|
|
378
|
+
className="text-[11px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold disabled:opacity-50"
|
|
379
|
+
>{submitting ? 'Creating…' : 'Confirm create'}</button>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const inputCls = 'text-[11px] px-2 py-1 border border-[var(--border)] rounded bg-[var(--bg-secondary)] w-full';
|
|
386
|
+
|
|
387
|
+
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
388
|
+
return (
|
|
389
|
+
<div>
|
|
390
|
+
<div className="text-[11px] mb-0.5">{label}</div>
|
|
391
|
+
{children}
|
|
392
|
+
{hint && <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">{hint}</div>}
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Mirror the slug rule in ScheduleCreateModal so quick-create + manual create
|
|
398
|
+
// produce comparable prompt filenames.
|
|
399
|
+
function slugifyName(s: string): string {
|
|
400
|
+
return s.toLowerCase()
|
|
401
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
402
|
+
.replace(/^-+|-+$/g, '')
|
|
403
|
+
.slice(0, 50);
|
|
404
|
+
}
|
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
import { useCallback, useEffect, useState } from 'react';
|
|
16
16
|
import ScheduleCreateModal from './ScheduleCreateModal';
|
|
17
|
+
import ScheduleQuickCreate from './ScheduleQuickCreate';
|
|
17
18
|
|
|
18
19
|
interface Schedule {
|
|
19
20
|
id: string;
|
|
20
21
|
name: string;
|
|
21
22
|
enabled: boolean;
|
|
22
|
-
body_kind: 'pipeline' | '
|
|
23
|
+
body_kind: 'pipeline' | 'prompt';
|
|
23
24
|
body_ref: string;
|
|
24
25
|
input: Record<string, unknown>;
|
|
25
26
|
skills: string[];
|
|
@@ -66,6 +67,7 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
66
67
|
const [runsBySchedule, setRunsBySchedule] = useState<Record<string, ScheduleRun[]>>({});
|
|
67
68
|
const [filter, setFilter] = useState<Filter>('all');
|
|
68
69
|
const [showCreate, setShowCreate] = useState(false);
|
|
70
|
+
const [showQuickCreate, setShowQuickCreate] = useState(false);
|
|
69
71
|
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
|
70
72
|
|
|
71
73
|
const refresh = useCallback(async () => {
|
|
@@ -190,6 +192,11 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
190
192
|
</p>
|
|
191
193
|
</div>
|
|
192
194
|
<div className="flex items-center gap-2">
|
|
195
|
+
<button
|
|
196
|
+
onClick={() => setShowQuickCreate(true)}
|
|
197
|
+
className="text-[10px] px-3 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)]/10"
|
|
198
|
+
title="Describe in natural language; Forge parses + you confirm"
|
|
199
|
+
>✨ Quick create</button>
|
|
193
200
|
<button
|
|
194
201
|
onClick={() => setShowCreate(true)}
|
|
195
202
|
className="text-[10px] px-3 py-1 bg-[var(--accent)] text-[var(--bg-primary)] rounded font-semibold hover:opacity-90"
|
|
@@ -252,6 +259,12 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
252
259
|
onCreated={() => { setShowCreate(false); void refresh(); }}
|
|
253
260
|
/>
|
|
254
261
|
)}
|
|
262
|
+
{showQuickCreate && (
|
|
263
|
+
<ScheduleQuickCreate
|
|
264
|
+
onClose={() => setShowQuickCreate(false)}
|
|
265
|
+
onCreated={() => { setShowQuickCreate(false); void refresh(); }}
|
|
266
|
+
/>
|
|
267
|
+
)}
|
|
255
268
|
{editingSchedule && (
|
|
256
269
|
<ScheduleCreateModal
|
|
257
270
|
existing={editingSchedule}
|
|
@@ -503,10 +516,9 @@ function RunRow({ run: r, isPipelineBody, onViewPipeline }: {
|
|
|
503
516
|
isPipelineBody: boolean;
|
|
504
517
|
onViewPipeline?: (pipelineId: string) => void;
|
|
505
518
|
}) {
|
|
506
|
-
// body_output/error captured for
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
// to expanded so the user doesn't have to hunt.
|
|
519
|
+
// body_output/error captured for prompt runs aren't navigable to a
|
|
520
|
+
// pipeline detail page; the only place to surface them is right here,
|
|
521
|
+
// inline. Failed runs default to expanded so the user doesn't have to hunt.
|
|
510
522
|
// Surface ALL three stages — trigger / body / action — instead of
|
|
511
523
|
// just body status. Skipped or failed action was previously
|
|
512
524
|
// invisible (e.g. "body status was started" race where action got
|
|
@@ -514,7 +526,7 @@ function RunRow({ run: r, isPipelineBody, onViewPipeline }: {
|
|
|
514
526
|
// to see WHY chat/email/telegram never fired).
|
|
515
527
|
const actionFailedOrSkipped = r.action_status === 'failed' || r.action_status === 'skipped';
|
|
516
528
|
const [expanded, setExpanded] = useState(r.status === 'failed' || actionFailedOrSkipped);
|
|
517
|
-
const canOpenPipeline = isPipelineBody && !!onViewPipeline
|
|
529
|
+
const canOpenPipeline = isPipelineBody && !!onViewPipeline;
|
|
518
530
|
const hasDetails = !!(r.body_output || r.error || r.action_error);
|
|
519
531
|
|
|
520
532
|
return (
|
|
@@ -1379,8 +1379,16 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1379
1379
|
const agentsCfg: Record<string, any> = { ...(prev.agents || {}) };
|
|
1380
1380
|
for (const a of updated) {
|
|
1381
1381
|
const existing = agentsCfg[a.id] || {};
|
|
1382
|
+
// NOTE: do NOT write `cliType` from the entry here. cliType is the
|
|
1383
|
+
// load-time discriminator between agents and CLI profiles (see the
|
|
1384
|
+
// filter ~line 1349 — `if (cfg.cliType || cfg.base) continue`).
|
|
1385
|
+
// The Agents-add form seeds newAgent.cliType for cliDefaults lookup
|
|
1386
|
+
// (taskFlags / resumeFlag presets), but persisting it would make
|
|
1387
|
+
// the new agent show up as a profile on reload. `...existing`
|
|
1388
|
+
// carries forward cliType for detected agents whose inline selector
|
|
1389
|
+
// wrote it via setSettings directly (line ~1540).
|
|
1382
1390
|
agentsCfg[a.id] = {
|
|
1383
|
-
...existing, // preserve profile-specific fields
|
|
1391
|
+
...existing, // preserve profile-specific fields + any prior cliType
|
|
1384
1392
|
name: a.name,
|
|
1385
1393
|
path: a.path,
|
|
1386
1394
|
enabled: a.enabled,
|
|
@@ -1391,7 +1399,6 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1391
1399
|
models: a.models,
|
|
1392
1400
|
skipPermissionsFlag: a.skipPermissionsFlag,
|
|
1393
1401
|
requiresTTY: a.requiresTTY,
|
|
1394
|
-
cliType: (a as any).cliType || existing.cliType,
|
|
1395
1402
|
};
|
|
1396
1403
|
}
|
|
1397
1404
|
const claude = updated.find(a => a.id === 'claude');
|
|
@@ -1437,6 +1444,15 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1437
1444
|
|
|
1438
1445
|
const addAgent = () => {
|
|
1439
1446
|
if (!newAgent.id || !newAgent.path) return;
|
|
1447
|
+
// settings.agents is one map keyed by id — agents + CLI profiles + API
|
|
1448
|
+
// profiles all coexist. A duplicate id silently overwrites whichever
|
|
1449
|
+
// entry was there first. Block the add and tell the user why.
|
|
1450
|
+
if (agents.some(a => a.id === newAgent.id) || settings.agents?.[newAgent.id]) {
|
|
1451
|
+
const existing = settings.agents?.[newAgent.id] as any;
|
|
1452
|
+
const kind = existing?.type === 'api' ? 'API profile' : existing?.cliType || existing?.base ? 'CLI profile' : 'agent';
|
|
1453
|
+
alert(`id "${newAgent.id}" is already in use by a ${kind}. Pick a different id.`);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1440
1456
|
const entry: AgentEntry = {
|
|
1441
1457
|
...newAgent,
|
|
1442
1458
|
enabled: true,
|
|
@@ -1743,11 +1759,34 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
1743
1759
|
))}
|
|
1744
1760
|
|
|
1745
1761
|
<div className="flex gap-2 mt-1">
|
|
1762
|
+
{/* Functional setSettings — non-functional form captures a stale
|
|
1763
|
+
`settings` snapshot, which clobbers any agent that was saved
|
|
1764
|
+
between this component's last render and the Add click
|
|
1765
|
+
(debouncedSave for the Agents section fires 1s later, so the
|
|
1766
|
+
window is real). Also: settings.agents is a single map keyed
|
|
1767
|
+
by id — agents + CLI profiles + API profiles share it — so
|
|
1768
|
+
reusing an id silently overwrites. Block dups with an alert. */}
|
|
1746
1769
|
<AddProfileForm type="cli" baseAgents={agents.filter(a => !a.isProfile && a.detected)} onAdd={(id, cfg) => {
|
|
1747
|
-
setSettings(
|
|
1770
|
+
setSettings((prev: any) => {
|
|
1771
|
+
if (prev.agents?.[id]) {
|
|
1772
|
+
const existing = prev.agents[id] as any;
|
|
1773
|
+
const kind = existing.type === 'api' ? 'API profile' : existing.cliType || existing.base ? 'CLI profile' : 'agent';
|
|
1774
|
+
alert(`id "${id}" is already in use by a ${kind}. Pick a different id.`);
|
|
1775
|
+
return prev;
|
|
1776
|
+
}
|
|
1777
|
+
return { ...prev, agents: { ...(prev.agents || {}), [id]: cfg } };
|
|
1778
|
+
});
|
|
1748
1779
|
}} />
|
|
1749
1780
|
<AddProfileForm type="api" baseAgents={[]} onAdd={(id, cfg) => {
|
|
1750
|
-
setSettings(
|
|
1781
|
+
setSettings((prev: any) => {
|
|
1782
|
+
if (prev.agents?.[id]) {
|
|
1783
|
+
const existing = prev.agents[id] as any;
|
|
1784
|
+
const kind = existing.type === 'api' ? 'API profile' : existing.cliType || existing.base ? 'CLI profile' : 'agent';
|
|
1785
|
+
alert(`id "${id}" is already in use by a ${kind}. Pick a different id.`);
|
|
1786
|
+
return prev;
|
|
1787
|
+
}
|
|
1788
|
+
return { ...prev, agents: { ...(prev.agents || {}), [id]: cfg } };
|
|
1789
|
+
});
|
|
1751
1790
|
}} />
|
|
1752
1791
|
</div>
|
|
1753
1792
|
</div>
|
package/lib/forge-mcp-server.ts
CHANGED
|
@@ -394,6 +394,90 @@ function createForgeMcpServer(sessionId: string): McpServer {
|
|
|
394
394
|
}
|
|
395
395
|
);
|
|
396
396
|
|
|
397
|
+
// ── list_connectors ────────────────────────────
|
|
398
|
+
// V3 ai-orchestration MVP — let an agent discover what installed
|
|
399
|
+
// connectors + tools it can call. Pairs with call_connector below.
|
|
400
|
+
server.tool(
|
|
401
|
+
'list_connectors',
|
|
402
|
+
'List installed Forge connectors and their callable tools. Use this BEFORE call_connector to discover what tool ids exist and what parameters each expects. Returns id, tool name, description, parameter schema, destructive flag, and (when present) author-provided returns hint.',
|
|
403
|
+
{},
|
|
404
|
+
async () => {
|
|
405
|
+
try {
|
|
406
|
+
const { listInstalledConnectors } = await import('./connectors/registry');
|
|
407
|
+
const connectors = listInstalledConnectors().filter((c) => c.enabled);
|
|
408
|
+
if (connectors.length === 0) {
|
|
409
|
+
return {
|
|
410
|
+
content: [{ type: 'text', text: 'No connectors installed. Install one from Settings → Marketplace.' }],
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
type ToolSpec = { description?: string; parameters?: Record<string, unknown>; destructive?: boolean; returns?: string; protocol?: string };
|
|
414
|
+
const out: Array<{
|
|
415
|
+
plugin_id: string;
|
|
416
|
+
name: string;
|
|
417
|
+
version: string;
|
|
418
|
+
tools: Array<{ name: string; description: string; parameters?: Record<string, unknown>; destructive?: boolean; returns?: string; protocol?: string }>;
|
|
419
|
+
}> = [];
|
|
420
|
+
for (const c of connectors) {
|
|
421
|
+
const def = c.definition as { id: string; name: string; version: string; tools?: Record<string, ToolSpec>; connectors?: Array<{ id: string; tools: Record<string, ToolSpec> }> };
|
|
422
|
+
// Flatten: single-entry connectors expose tools at top level, suite
|
|
423
|
+
// connectors (Atlassian-style) under .connectors[].tools.
|
|
424
|
+
const sources: Array<[string, Record<string, ToolSpec>]> = [];
|
|
425
|
+
if (def.tools) sources.push([def.id, def.tools]);
|
|
426
|
+
for (const entry of def.connectors || []) sources.push([entry.id, entry.tools]);
|
|
427
|
+
for (const [pluginId, tools] of sources) {
|
|
428
|
+
out.push({
|
|
429
|
+
plugin_id: pluginId,
|
|
430
|
+
name: def.name,
|
|
431
|
+
version: def.version,
|
|
432
|
+
tools: Object.entries(tools).map(([toolName, t]) => ({
|
|
433
|
+
name: toolName,
|
|
434
|
+
description: t.description || '(no description)',
|
|
435
|
+
...(t.parameters ? { parameters: t.parameters } : {}),
|
|
436
|
+
...(t.destructive ? { destructive: true } : {}),
|
|
437
|
+
...(t.returns ? { returns: t.returns } : {}),
|
|
438
|
+
...(t.protocol ? { protocol: t.protocol } : {}),
|
|
439
|
+
})),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
|
444
|
+
} catch (err: any) {
|
|
445
|
+
return { content: [{ type: 'text', text: `Error listing connectors: ${err.message}` }] };
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// ── call_connector ─────────────────────────────
|
|
451
|
+
// Invoke one connector tool. Same backend as POST /api/connector-tool
|
|
452
|
+
// (single internal entry point — anything pipelines / jobs / chat already
|
|
453
|
+
// call is what an ai-orchestration-skilled agent gets too).
|
|
454
|
+
server.tool(
|
|
455
|
+
'call_connector',
|
|
456
|
+
'Call one installed connector tool. Use list_connectors first to find plugin_id + tool_name. Returns the tool result text; sets is_error=true on failure. Destructive tools (add_comment, send_message, etc.) require explicit user confirmation in the surrounding workflow — the call itself is not blocked here.',
|
|
457
|
+
{
|
|
458
|
+
plugin_id: z.string().describe("Connector id from list_connectors (e.g. 'mantis', 'gitlab', 'teams')."),
|
|
459
|
+
tool: z.string().describe("Tool name within the connector (e.g. 'get_bug', 'search_mrs', 'send_message')."),
|
|
460
|
+
input: z.record(z.string(), z.any()).optional().describe('Parameter object matching the tool schema returned by list_connectors. Omit for parameter-less tools.'),
|
|
461
|
+
},
|
|
462
|
+
async (params) => {
|
|
463
|
+
try {
|
|
464
|
+
const { dispatchTool } = await import('./chat/tool-dispatcher');
|
|
465
|
+
const name = `${params.plugin_id}.${params.tool}`;
|
|
466
|
+
const result = await dispatchTool({
|
|
467
|
+
id: `mcp-${Date.now()}`,
|
|
468
|
+
name,
|
|
469
|
+
input: params.input ?? {},
|
|
470
|
+
});
|
|
471
|
+
return {
|
|
472
|
+
content: [{ type: 'text', text: result.content }],
|
|
473
|
+
...(result.is_error ? { isError: true } : {}),
|
|
474
|
+
};
|
|
475
|
+
} catch (err: any) {
|
|
476
|
+
return { content: [{ type: 'text', text: `Error calling ${params.plugin_id}.${params.tool}: ${err.message}` }], isError: true };
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
|
|
397
481
|
// ── get_pipeline_status ────────────────────────
|
|
398
482
|
server.tool(
|
|
399
483
|
'get_pipeline_status',
|
package/lib/init.ts
CHANGED
|
@@ -86,6 +86,13 @@ export function ensureInitialized() {
|
|
|
86
86
|
|
|
87
87
|
time('logger', () => { try { const { initLogger } = require('./logger'); initLogger(); } catch {} });
|
|
88
88
|
time('migrateDataDir', () => { try { const { migrateDataDir } = require('./dirs'); migrateDataDir(); } catch {} });
|
|
89
|
+
time('ensureScratchProject', () => {
|
|
90
|
+
// Synthetic 'scratch' project under <dataDir>/scratch — default
|
|
91
|
+
// workspace for prompt schedules / chat-launched temp tasks that
|
|
92
|
+
// don't care about a real project.
|
|
93
|
+
try { const { ensureScratchProject } = require('./projects'); ensureScratchProject(); }
|
|
94
|
+
catch (e) { console.warn('[init] ensureScratchProject failed:', (e as Error).message); }
|
|
95
|
+
});
|
|
89
96
|
time('migrateSecrets', migrateSecrets);
|
|
90
97
|
time('migratePluginSecrets', () => {
|
|
91
98
|
try {
|