@aion0/forge 0.9.12 → 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.
@@ -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' | 'skill' | 'connector_tool';
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 connector_tool runs (target_id starts
507
- // ct_*) and skill runs aren't navigable to a pipeline detail page; the
508
- // only place to surface them is right here, inline. Failed runs default
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 && !r.target_id.startsWith('ct_');
529
+ const canOpenPipeline = isPipelineBody && !!onViewPipeline;
518
530
  const hasDetails = !!(r.body_output || r.error || r.action_error);
519
531
 
520
532
  return (
@@ -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 {
package/lib/projects.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
1
+ import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { loadSettings } from './settings';
4
+ import { getDataDir } from './dirs';
4
5
 
5
6
  export interface LocalProject {
6
7
  name: string;
@@ -12,6 +13,67 @@ export interface LocalProject {
12
13
  lastModified: string;
13
14
  }
14
15
 
16
+ /** Reserved name for the synthetic "scratch" project that lives under
17
+ * <dataDir>/scratch. Default workspace for tasks that don't need a real
18
+ * project (prompt schedules calling connectors / chat-launched temp tasks
19
+ * / anything that just talks to APIs and doesn't touch files). */
20
+ export const SCRATCH_PROJECT_NAME = 'scratch';
21
+
22
+ /** Materialize <dataDir>/scratch on first call. Idempotent. Returns the
23
+ * project path. Called from init.ts on startup so the dir exists before
24
+ * any task tries to cd into it. */
25
+ export function ensureScratchProject(): string {
26
+ const dir = join(getDataDir(), 'scratch');
27
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
28
+ const claudeMd = join(dir, 'CLAUDE.md');
29
+ if (!existsSync(claudeMd)) {
30
+ try {
31
+ writeFileSync(claudeMd,
32
+ '# Forge Scratch\n\n' +
33
+ 'Default workspace for tasks that do not target a specific project.\n' +
34
+ 'Files here are managed by Forge (per-instance, follows the data dir).\n' +
35
+ 'Safe to wipe by hand if it accumulates clutter.\n',
36
+ 'utf-8',
37
+ );
38
+ } catch { /* best-effort */ }
39
+ }
40
+ // Scratch is Forge-owned (no user hand-rolled .mcp.json to defy), so
41
+ // unlike real projects we materialize the root .mcp.json directly here.
42
+ // Without it, prompt-mode schedule tasks running in scratch can't see
43
+ // the Forge MCP server (claude CLI only auto-discovers root .mcp.json,
44
+ // not .forge/mcp.json). Always rewrite to keep mcpPort + user-managed
45
+ // entries in sync.
46
+ try {
47
+ const mcpPort = Number(process.env.MCP_PORT) || 8406;
48
+ let userServers: Record<string, unknown> = {};
49
+ try { userServers = loadSettings().mcpServers || {}; } catch {}
50
+ const cfg = {
51
+ mcpServers: {
52
+ forge: { type: 'sse', url: `http://localhost:${mcpPort}/sse` },
53
+ ...userServers,
54
+ },
55
+ };
56
+ writeFileSync(join(dir, '.mcp.json'), JSON.stringify(cfg, null, 2), 'utf-8');
57
+ } catch { /* best-effort */ }
58
+ return dir;
59
+ }
60
+
61
+ function scratchProject(): LocalProject {
62
+ const path = ensureScratchProject();
63
+ let mtime: string;
64
+ try { mtime = statSync(path).mtime.toISOString(); }
65
+ catch { mtime = new Date().toISOString(); }
66
+ return {
67
+ name: SCRATCH_PROJECT_NAME,
68
+ path,
69
+ root: getDataDir(),
70
+ hasGit: false,
71
+ hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
72
+ language: null,
73
+ lastModified: mtime,
74
+ };
75
+ }
76
+
15
77
  export function scanProjects(): LocalProject[] {
16
78
  const settings = loadSettings();
17
79
  const roots = settings.projectRoots;
@@ -52,7 +114,10 @@ export function scanProjects(): LocalProject[] {
52
114
  }
53
115
  }
54
116
 
55
- return projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
117
+ // Prepend the synthetic scratch project so the UI surfaces it as a
118
+ // first-class option. It's stamped with the current date so it tends
119
+ // to sort to the top — that's intentional: it's the default fallback.
120
+ return [scratchProject(), ...projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified))];
56
121
  }
57
122
 
58
123
  function detectLanguage(projectPath: string): string | null {