@aion0/forge 0.6.1 → 0.8.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.
Files changed (145) hide show
  1. package/.forge/mcp.json +8 -0
  2. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/01-settings.md +5 -5
  3. package/.forge/worktrees/pipeline-0a33c50d/lib/help-docs/07-projects.md +1 -1
  4. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/01-settings.md +5 -5
  5. package/.forge/worktrees/pipeline-2ba01c10/lib/help-docs/07-projects.md +1 -1
  6. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/01-settings.md +5 -5
  7. package/.forge/worktrees/pipeline-3156a8b3/lib/help-docs/07-projects.md +1 -1
  8. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/01-settings.md +5 -5
  9. package/.forge/worktrees/pipeline-316c6574/lib/help-docs/07-projects.md +1 -1
  10. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/01-settings.md +5 -5
  11. package/.forge/worktrees/pipeline-44a94121/lib/help-docs/07-projects.md +1 -1
  12. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +5 -5
  13. package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +1 -1
  14. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/01-settings.md +5 -5
  15. package/.forge/worktrees/pipeline-d1757a50/lib/help-docs/07-projects.md +1 -1
  16. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/01-settings.md +5 -5
  17. package/.forge/worktrees/pipeline-d59c2fe2/lib/help-docs/07-projects.md +1 -1
  18. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/01-settings.md +5 -5
  19. package/.forge/worktrees/pipeline-d6a6ef23/lib/help-docs/07-projects.md +1 -1
  20. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/01-settings.md +5 -5
  21. package/.forge/worktrees/pipeline-e7f78b7a/lib/help-docs/07-projects.md +1 -1
  22. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/01-settings.md +5 -5
  23. package/.forge/worktrees/pipeline-e97c13c7/lib/help-docs/07-projects.md +1 -1
  24. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/01-settings.md +5 -5
  25. package/.forge/worktrees/pipeline-ecd7cb0f/lib/help-docs/07-projects.md +1 -1
  26. package/CLAUDE.md +2 -2
  27. package/RELEASE_NOTES.md +101 -5
  28. package/app/api/auth/check/route.ts +18 -0
  29. package/app/api/browser-bridge/route.ts +70 -0
  30. package/app/api/chat/sessions/[id]/events/route.ts +17 -0
  31. package/app/api/chat/sessions/[id]/fork/route.ts +15 -0
  32. package/app/api/chat/sessions/[id]/messages/route.ts +21 -0
  33. package/app/api/chat/sessions/[id]/route.ts +23 -0
  34. package/app/api/chat/sessions/route.ts +12 -0
  35. package/app/api/chat/temper-ping/route.ts +18 -0
  36. package/app/api/chat-proxy/[...path]/route.ts +83 -0
  37. package/app/api/connector-tool/route.ts +38 -0
  38. package/app/api/connectors/[id]/settings/route.ts +112 -0
  39. package/app/api/connectors/route.ts +108 -0
  40. package/app/api/health/tools/route.ts +14 -0
  41. package/app/api/issue-scanner-gitlab/route.ts +95 -0
  42. package/app/api/jobs/[id]/reset_dedup/route.ts +15 -0
  43. package/app/api/jobs/[id]/route.ts +31 -0
  44. package/app/api/jobs/[id]/run/route.ts +44 -0
  45. package/app/api/jobs/[id]/runs/[runId]/route.ts +15 -0
  46. package/app/api/jobs/[id]/runs/route.ts +15 -0
  47. package/app/api/jobs/preview/route.ts +193 -0
  48. package/app/api/jobs/route.ts +36 -0
  49. package/app/api/notify/test/route.ts +39 -7
  50. package/app/api/pipelines/[id]/route.ts +10 -1
  51. package/app/api/pipelines/route.ts +16 -2
  52. package/app/api/plugins/route.ts +40 -8
  53. package/app/api/project-sessions/route.ts +50 -10
  54. package/app/api/settings/route.ts +13 -0
  55. package/app/chat/page.tsx +531 -0
  56. package/bin/forge-server.mjs +3 -1
  57. package/cli/chat.ts +283 -0
  58. package/cli/jobs.ts +176 -0
  59. package/cli/mw.ts +28 -1
  60. package/cli/worktree.ts +245 -0
  61. package/components/ConnectorsPanel.tsx +275 -0
  62. package/components/Dashboard.tsx +90 -37
  63. package/components/JobsView.tsx +361 -0
  64. package/components/LogViewer.tsx +12 -2
  65. package/components/PipelineView.tsx +275 -56
  66. package/components/PluginsPanel.tsx +3 -1
  67. package/components/SettingsModal.tsx +229 -40
  68. package/components/SkillsPanel.tsx +12 -4
  69. package/components/TerminalLauncher.tsx +3 -1
  70. package/components/WebTerminal.tsx +32 -9
  71. package/components/WorkspaceView.tsx +18 -10
  72. package/docs/Connector-DeclarativeExtract-Handoff.md +471 -0
  73. package/docs/Connector-DeclarativeExtract-Spec.md +364 -0
  74. package/docs/Implementation-Plan-Browser-Agent.md +487 -0
  75. package/docs/Jobs-Design.md +240 -0
  76. package/docs/LOCAL-DEPLOY.md +3 -3
  77. package/docs/RFC-Browser-Connectors.md +509 -0
  78. package/lib/agents/index.ts +44 -6
  79. package/lib/agents/types.ts +1 -1
  80. package/lib/browser-bridge-standalone.ts +317 -0
  81. package/lib/builtin-plugins/github-api.yaml +93 -0
  82. package/lib/builtin-plugins/gitlab.yaml +860 -0
  83. package/lib/builtin-plugins/mantis.probe.js +176 -0
  84. package/lib/builtin-plugins/mantis.yaml +964 -0
  85. package/lib/builtin-plugins/pmdb.yaml +178 -0
  86. package/lib/builtin-plugins/teams.yaml +913 -0
  87. package/lib/chat/__test__/smoke.ts +30 -0
  88. package/lib/chat/agent-loop.ts +523 -0
  89. package/lib/chat/bridge-client.ts +59 -0
  90. package/lib/chat/llm/anthropic.ts +99 -0
  91. package/lib/chat/llm/index.ts +20 -0
  92. package/lib/chat/llm/openai.ts +215 -0
  93. package/lib/chat/llm/types.ts +42 -0
  94. package/lib/chat/local-memory.ts +300 -0
  95. package/lib/chat/memory-store.ts +87 -0
  96. package/lib/chat/memory-tools.ts +157 -0
  97. package/lib/chat/protocols/http.ts +118 -0
  98. package/lib/chat/protocols/shell.ts +101 -0
  99. package/lib/chat/proxy.ts +51 -0
  100. package/lib/chat/session-store.ts +272 -0
  101. package/lib/chat/telegram-bridge.ts +276 -0
  102. package/lib/chat/temper.ts +281 -0
  103. package/lib/chat/tool-dispatcher.ts +190 -0
  104. package/lib/chat/types.ts +50 -0
  105. package/lib/chat-standalone.ts +286 -0
  106. package/lib/crypto.ts +1 -1
  107. package/lib/health.ts +131 -0
  108. package/lib/help-docs/00-overview.md +2 -1
  109. package/lib/help-docs/01-settings.md +46 -25
  110. package/lib/help-docs/07-projects.md +1 -1
  111. package/lib/help-docs/10-troubleshooting.md +10 -2
  112. package/lib/help-docs/16-gitlab-autofix.md +114 -0
  113. package/lib/help-docs/17-connectors.md +322 -0
  114. package/lib/help-docs/18-chrome-mcp.md +134 -0
  115. package/lib/help-docs/19-jobs.md +140 -0
  116. package/lib/help-docs/20-mantis-bug-fix.md +115 -0
  117. package/lib/help-docs/CLAUDE.md +10 -0
  118. package/lib/init.ts +137 -50
  119. package/lib/iso-time.ts +30 -0
  120. package/lib/issue-scanner-gitlab.ts +281 -0
  121. package/lib/jobs/dispatcher.ts +217 -0
  122. package/lib/jobs/scheduler.ts +334 -0
  123. package/lib/jobs/store.ts +319 -0
  124. package/lib/jobs/types.ts +117 -0
  125. package/lib/pipeline-scheduler.ts +1 -6
  126. package/lib/pipeline.ts +790 -10
  127. package/lib/plugins/registry.ts +133 -8
  128. package/lib/plugins/templates.ts +83 -0
  129. package/lib/plugins/types.ts +140 -1
  130. package/lib/session-watcher.ts +36 -10
  131. package/lib/settings.ts +65 -33
  132. package/lib/skills.ts +3 -1
  133. package/lib/task-manager.ts +50 -22
  134. package/lib/telegram-bot.ts +71 -0
  135. package/lib/terminal-standalone.ts +58 -36
  136. package/lib/workspace/orchestrator.ts +1 -0
  137. package/middleware.ts +10 -0
  138. package/package.json +3 -2
  139. package/scripts/bench/README.md +1 -1
  140. package/scripts/bench/tasks/01-text-utils/validator.sh +1 -1
  141. package/scripts/bench/tasks/02-pagination/setup.sh +1 -1
  142. package/scripts/bench/tasks/02-pagination/validator.sh +1 -1
  143. package/scripts/bench/tasks/03-bug-fix/setup.sh +1 -1
  144. package/scripts/bench/tasks/03-bug-fix/validator.sh +1 -1
  145. package/src/core/db/database.ts +21 -12
@@ -0,0 +1,31 @@
1
+ /**
2
+ * GET /api/jobs/<id> { job, recent_runs: [..20..] }
3
+ * PATCH /api/jobs/<id> partial update
4
+ * DELETE /api/jobs/<id> cascade
5
+ */
6
+
7
+ import { NextResponse } from 'next/server';
8
+ import { getJob, updateJob, deleteJob, listRuns } from '@/lib/jobs/store';
9
+
10
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
11
+ const { id } = await params;
12
+ const job = getJob(id);
13
+ if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
14
+ return NextResponse.json({ job, recent_runs: listRuns(id, 20) });
15
+ }
16
+
17
+ export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
18
+ const { id } = await params;
19
+ const job = getJob(id);
20
+ if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
21
+ let body: any = {};
22
+ try { body = await req.json(); } catch {}
23
+ const ok = updateJob(id, body);
24
+ return NextResponse.json({ ok, job: getJob(id) });
25
+ }
26
+
27
+ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
28
+ const { id } = await params;
29
+ const ok = deleteJob(id);
30
+ return NextResponse.json({ ok });
31
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * POST /api/jobs/<id>/run — manually fire one tick.
3
+ *
4
+ * Inserts the run row synchronously, returns its id (202), and runs the
5
+ * actual work in the background. Poll GET /api/jobs/<id>/runs/<run_id>
6
+ * (or /api/jobs/<id>) to observe completion.
7
+ *
8
+ * Query params:
9
+ * reset_dedup=1 wipe job_seen before running. Useful for "force re-dispatch
10
+ * the same items I tested with last time" while iterating
11
+ * on a pipeline. Recorded as note on the run for audit.
12
+ */
13
+
14
+ import { NextResponse } from 'next/server';
15
+ import { prepareRun, executeRun } from '@/lib/jobs/scheduler';
16
+ import { resetDedup } from '@/lib/jobs/store';
17
+
18
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
19
+ const { id } = await params;
20
+ const url = new URL(req.url);
21
+ const shouldReset = url.searchParams.get('reset_dedup') === '1';
22
+ let removedDedupKeys = 0;
23
+ if (shouldReset) {
24
+ try { removedDedupKeys = resetDedup(id); }
25
+ catch (e) {
26
+ return NextResponse.json({ error: 'reset_dedup failed: ' + (e as Error).message }, { status: 500 });
27
+ }
28
+ }
29
+ let prepared;
30
+ try {
31
+ prepared = prepareRun(id, 'manual');
32
+ } catch (e) {
33
+ return NextResponse.json({ error: (e as Error).message }, { status: 404 });
34
+ }
35
+ void executeRun(prepared.job, prepared.runId).catch((err) => {
36
+ console.error('[jobs] manual run crashed', err);
37
+ });
38
+ return NextResponse.json({
39
+ accepted: true,
40
+ run_id: prepared.runId,
41
+ dedup_reset: shouldReset,
42
+ removed_dedup_keys: shouldReset ? removedDedupKeys : undefined,
43
+ }, { status: 202 });
44
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * GET /api/jobs/<id>/runs/<runId> — { run, dispatches: [...] }
3
+ */
4
+
5
+ import { NextResponse } from 'next/server';
6
+ import { getJob, getRun, listDispatches } from '@/lib/jobs/store';
7
+
8
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string; runId: string }> }) {
9
+ const { id, runId } = await params;
10
+ const job = getJob(id);
11
+ if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
12
+ const run = getRun(runId);
13
+ if (!run || run.job_id !== id) return NextResponse.json({ error: 'run not found' }, { status: 404 });
14
+ return NextResponse.json({ run, dispatches: listDispatches(runId) });
15
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * GET /api/jobs/<id>/runs?limit=20 — list runs for a job.
3
+ */
4
+
5
+ import { NextResponse } from 'next/server';
6
+ import { getJob, listRuns } from '@/lib/jobs/store';
7
+
8
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
9
+ const { id } = await params;
10
+ const job = getJob(id);
11
+ if (!job) return NextResponse.json({ error: 'job not found' }, { status: 404 });
12
+ const url = new URL(req.url);
13
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') || 20)));
14
+ return NextResponse.json({ runs: listRuns(id, limit) });
15
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * POST /api/jobs/preview
3
+ *
4
+ * Dry-run a Job's source_tool with its source_input and return the first
5
+ * parsed item. The Jobs editor uses this to auto-build input_template:
6
+ * "given the bug Mantis just returned, what {{item.X}} expression maps
7
+ * each pipeline input field?" — eliminating the friction where users
8
+ * had to know in advance that get_bug returns `id` not `bug_id` etc.
9
+ *
10
+ * NO dispatch happens. NO dedup mutation. NO job row required (this works
11
+ * before the Job is saved, that's the whole point).
12
+ *
13
+ * Request body:
14
+ * {
15
+ * source_connector: string,
16
+ * source_tool: string,
17
+ * source_input?: object,
18
+ * items_path?: string, // empty / dotted path
19
+ * pipeline_inputs?: string[] // optional: workflow's input field names —
20
+ * // if given, response includes a suggested
21
+ * // input_template with fuzzy-matched mappings
22
+ * }
23
+ *
24
+ * Response:
25
+ * {
26
+ * ok: true,
27
+ * sample_item: object, // first item from the connector
28
+ * item_keys: string[], // top-level keys on sample_item
29
+ * suggested_template?: Record<string,string> // pipeline_input → "{{item.X}}"
30
+ * }
31
+ *
32
+ * or { ok: false, error, ...debug } on failure
33
+ */
34
+
35
+ import { NextResponse } from 'next/server';
36
+ import { dispatchTool } from '@/lib/chat/tool-dispatcher';
37
+
38
+ function pickPath(obj: unknown, path: string): unknown {
39
+ if (!path) return obj;
40
+ const parts = path.split('.');
41
+ let cur: any = obj;
42
+ for (const p of parts) {
43
+ if (cur == null || typeof cur !== 'object') return undefined;
44
+ cur = cur[p];
45
+ }
46
+ return cur;
47
+ }
48
+
49
+ /**
50
+ * Best-effort match a workflow input name to a key on the sample item.
51
+ * The mantis case (bug_id ← id, base_branch ← nothing) drove this list:
52
+ * - Exact match wins.
53
+ * - Strip _id / Id / ID suffixes both sides → check.
54
+ * - 'description' fuzz: desc, body, content all work.
55
+ * - 'summary' fuzz: title, name.
56
+ * Returns the matching item-key or null. NEVER guesses across unrelated names.
57
+ */
58
+ function suggestMapping(inputName: string, itemKeys: string[]): string | null {
59
+ const lc = inputName.toLowerCase();
60
+ const lcMap = new Map(itemKeys.map(k => [k.toLowerCase(), k]));
61
+
62
+ // 1. Exact match.
63
+ if (lcMap.has(lc)) return lcMap.get(lc)!;
64
+
65
+ // 2. ID-shaped fields. The interesting cases:
66
+ // - pipeline input "bug_id" + item has "id" → match "id"
67
+ // - pipeline input "bug_id" + item has "bug" → match "bug"
68
+ // - pipeline input "bug_id" + item has "bugId" → match "bugId"
69
+ // - pipeline input "id" + item has "issue_id" → match "issue_id"
70
+ const endsWithId = /_?id$/i.test(lc);
71
+ const stripped = lc.replace(/_?id$/i, '');
72
+ if (endsWithId) {
73
+ if (lcMap.has('id')) return lcMap.get('id')!; // ← the missing case
74
+ if (stripped && lcMap.has(stripped)) return lcMap.get(stripped)!;
75
+ if (stripped && lcMap.has(stripped + '_id')) return lcMap.get(stripped + '_id')!;
76
+ } else if (lc === 'id') {
77
+ for (const k of itemKeys) {
78
+ if (/_?id$/i.test(k.toLowerCase())) return k;
79
+ }
80
+ }
81
+
82
+ // 3. Synonym table — short, targeted. Add cases when a real connector
83
+ // surfaces them, not speculatively.
84
+ const synonyms: Record<string, string[]> = {
85
+ summary: ['title', 'name', 'subject'],
86
+ description: ['body', 'content', 'desc', 'text'],
87
+ assignee: ['handler', 'assigned_to', 'owner'],
88
+ reporter: ['author', 'created_by', 'submitted_by'],
89
+ priority: ['severity'],
90
+ category: ['keywords', 'labels', 'tags'], // mantis: 'keywords' col
91
+ url: ['web_url', 'link', 'href'],
92
+ };
93
+ for (const alt of synonyms[lc] || []) {
94
+ if (lcMap.has(alt)) return lcMap.get(alt)!;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ export async function POST(req: Request) {
100
+ let body: any;
101
+ try { body = await req.json(); }
102
+ catch { return NextResponse.json({ ok: false, error: 'invalid JSON body' }, { status: 400 }); }
103
+
104
+ const { source_connector, source_tool, source_input, items_path, pipeline_inputs } = body || {};
105
+ if (!source_connector || !source_tool) {
106
+ return NextResponse.json({ ok: false, error: 'source_connector and source_tool are required' }, { status: 400 });
107
+ }
108
+
109
+ const callName = `${source_connector}.${source_tool}`;
110
+ let toolResult;
111
+ try {
112
+ toolResult = await dispatchTool({
113
+ id: `jobs-preview-${Date.now()}`,
114
+ name: callName,
115
+ input: source_input || {},
116
+ });
117
+ } catch (e) {
118
+ return NextResponse.json({ ok: false, error: `connector call threw: ${(e as Error).message}` }, { status: 500 });
119
+ }
120
+
121
+ if (toolResult.is_error) {
122
+ return NextResponse.json({
123
+ ok: false,
124
+ error: `connector returned is_error=true`,
125
+ raw_preview: toolResult.content.slice(0, 800),
126
+ }, { status: 200 });
127
+ }
128
+
129
+ let parsed: unknown;
130
+ try { parsed = JSON.parse(toolResult.content); }
131
+ catch {
132
+ return NextResponse.json({
133
+ ok: false,
134
+ error: 'connector returned non-JSON content',
135
+ raw_preview: toolResult.content.slice(0, 800),
136
+ }, { status: 200 });
137
+ }
138
+
139
+ let items = pickPath(parsed, items_path || '');
140
+ // Same single-object-as-1-item-list logic as the scheduler, so the
141
+ // preview matches what runtime will see.
142
+ if (items && typeof items === 'object' && !Array.isArray(items)) {
143
+ items = [items];
144
+ }
145
+ if (!Array.isArray(items) || items.length === 0) {
146
+ const topKeys = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
147
+ ? Object.keys(parsed as Record<string, unknown>)
148
+ : [];
149
+ return NextResponse.json({
150
+ ok: false,
151
+ error: Array.isArray(items)
152
+ ? `items_path resolved to an empty array — no sample item available`
153
+ : `items_path "${items_path || '(empty)'}" did not resolve to an array or object`,
154
+ top_level_keys: topKeys,
155
+ }, { status: 200 });
156
+ }
157
+
158
+ const sampleItem = items[0] as Record<string, unknown>;
159
+ // Union of keys across ALL returned items, not just items[0]. Mantis
160
+ // bug rows occasionally omit fields when blank — e.g. a bug with no
161
+ // fix_schedule won't have that key on its object — so basing the
162
+ // template on a single sample misses real-world fields. Scan up to
163
+ // 20 items (covers >99% of typical job batches).
164
+ const keySet = new Set<string>();
165
+ const sampleCount = Math.min(items.length, 20);
166
+ for (let i = 0; i < sampleCount; i++) {
167
+ const it = items[i];
168
+ if (it && typeof it === 'object' && !Array.isArray(it)) {
169
+ for (const k of Object.keys(it as Record<string, unknown>)) keySet.add(k);
170
+ }
171
+ }
172
+ const itemKeys = Array.from(keySet);
173
+
174
+ let suggestedTemplate: Record<string, string> | undefined;
175
+ if (Array.isArray(pipeline_inputs) && pipeline_inputs.length > 0) {
176
+ suggestedTemplate = {};
177
+ for (const inputName of pipeline_inputs) {
178
+ if (typeof inputName !== 'string') continue;
179
+ const match = suggestMapping(inputName, itemKeys);
180
+ // If no fuzzy match found, leave it blank so the user knows it needs
181
+ // a constant (e.g. base_branch). NEVER guess across unrelated names.
182
+ suggestedTemplate[inputName] = match ? `{{item.${match}}}` : '';
183
+ }
184
+ }
185
+
186
+ return NextResponse.json({
187
+ ok: true,
188
+ sample_item: sampleItem,
189
+ item_keys: itemKeys,
190
+ suggested_template: suggestedTemplate,
191
+ total_items: items.length,
192
+ });
193
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * GET /api/jobs list all jobs
3
+ * POST /api/jobs create a job
4
+ */
5
+
6
+ import { NextResponse } from 'next/server';
7
+ import { listJobs, createJob } from '@/lib/jobs/store';
8
+
9
+ export async function GET() {
10
+ return NextResponse.json({ jobs: listJobs() });
11
+ }
12
+
13
+ export async function POST(req: Request) {
14
+ let body: any = {};
15
+ try { body = await req.json(); } catch {}
16
+ if (!body?.name || !body?.source_connector || !body?.source_tool || !body?.dedup_field || !body?.dispatch_type || !body?.dispatch_params) {
17
+ return NextResponse.json({ error: 'name, source_connector, source_tool, dedup_field, dispatch_type, dispatch_params are required' }, { status: 400 });
18
+ }
19
+ if (body.dispatch_type !== 'pipeline' && body.dispatch_type !== 'chat') {
20
+ return NextResponse.json({ error: "dispatch_type must be 'pipeline' or 'chat'" }, { status: 400 });
21
+ }
22
+ const job = createJob({
23
+ name: String(body.name),
24
+ enabled: body.enabled !== false,
25
+ schedule_interval_minutes: Number(body.schedule_interval_minutes) || 30,
26
+ source_connector: String(body.source_connector),
27
+ source_tool: String(body.source_tool),
28
+ source_input: body.source_input || {},
29
+ items_path: body.items_path || '',
30
+ dedup_field: String(body.dedup_field),
31
+ dispatch_type: body.dispatch_type,
32
+ dispatch_params: body.dispatch_params,
33
+ mark_existing_as_seen: body.mark_existing_as_seen !== false,
34
+ });
35
+ return NextResponse.json({ job });
36
+ }
@@ -9,25 +9,57 @@ export async function POST() {
9
9
  return NextResponse.json({ ok: false, error: 'Telegram bot token or chat ID not configured' });
10
10
  }
11
11
 
12
+ // telegramChatId may be a comma-separated whitelist. The test message
13
+ // goes to the FIRST id so a misconfigured later id doesn't fail the test.
14
+ const firstChatId = telegramChatId.split(',').map((s) => s.trim()).filter(Boolean)[0];
15
+ if (!firstChatId) {
16
+ return NextResponse.json({ ok: false, error: 'telegramChatId is empty / malformed' });
17
+ }
18
+
12
19
  try {
13
20
  const url = `https://api.telegram.org/bot${telegramBotToken}/sendMessage`;
21
+ // Plain text — no parse_mode — so an em-dash / asterisk / underscore
22
+ // can never trip Telegram's Markdown parser ("can't parse entities").
14
23
  const res = await fetch(url, {
15
24
  method: 'POST',
16
25
  headers: { 'Content-Type': 'application/json' },
17
26
  body: JSON.stringify({
18
- chat_id: telegramChatId,
19
- text: '✅ *Forge* — Test notification!\n\nTelegram notifications are working.',
20
- parse_mode: 'Markdown',
27
+ chat_id: firstChatId,
28
+ text: '✅ Forge — Test notification! Telegram notifications are working.',
21
29
  }),
22
30
  });
23
31
 
32
+ const body = await res.text();
24
33
  if (!res.ok) {
25
- const body = await res.text();
26
- return NextResponse.json({ ok: false, error: body });
34
+ // Surface Telegram's "description" — covers the common cases like
35
+ // "chat not found", "Unauthorized" (token revoked), "bot was blocked".
36
+ try {
37
+ const j = JSON.parse(body);
38
+ return NextResponse.json({ ok: false, error: `${res.status} ${j.description || body}` });
39
+ } catch {
40
+ return NextResponse.json({ ok: false, error: `${res.status} ${body}` });
41
+ }
27
42
  }
28
-
29
43
  return NextResponse.json({ ok: true });
30
44
  } catch (err: any) {
31
- return NextResponse.json({ ok: false, error: err.message });
45
+ // 'fetch failed' from Node is opaque — almost always corp SSL inspection
46
+ // (Zscaler / corporate firewall) or proxy / DNS / network. Surface
47
+ // concrete remediation in the alert so the user doesn't have to guess.
48
+ const msg = String(err?.message || err);
49
+ if (/fetch failed|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|UNABLE_TO_VERIFY|self.signed/i.test(msg)) {
50
+ const cause = err?.cause?.code || err?.cause?.message || '';
51
+ return NextResponse.json({
52
+ ok: false,
53
+ error: [
54
+ `${msg}${cause ? ' (cause: ' + cause + ')' : ''}`,
55
+ '',
56
+ 'Forge could not reach api.telegram.org. Common causes:',
57
+ ' • Corporate TLS inspection — set NODE_EXTRA_CA_CERTS=/path/to/corp-ca.pem then `forge server restart`',
58
+ ' • HTTP proxy required — set HTTPS_PROXY=http://proxy.corp:port then restart',
59
+ ' • Network / DNS — curl -v https://api.telegram.org/bot<TOKEN>/getMe',
60
+ ].join('\n'),
61
+ });
62
+ }
63
+ return NextResponse.json({ ok: false, error: msg });
32
64
  }
33
65
  }
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { getPipeline, cancelPipeline, deletePipeline, injectConversationMessage } from '@/lib/pipeline';
2
+ import { getPipeline, cancelPipeline, deletePipeline, injectConversationMessage, retryNode } from '@/lib/pipeline';
3
3
 
4
4
  // GET /api/pipelines/:id
5
5
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -25,6 +25,15 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
25
25
  return NextResponse.json({ ok });
26
26
  }
27
27
 
28
+ // Retry a single failed node — see lib/pipeline.ts retryNode for semantics.
29
+ if (action === 'retry-node') {
30
+ const { nodeId } = body;
31
+ if (!nodeId) return NextResponse.json({ error: 'nodeId required' }, { status: 400 });
32
+ const result = await retryNode(id, String(nodeId));
33
+ if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
34
+ return NextResponse.json({ ok: true });
35
+ }
36
+
28
37
  // Inject a message into a running conversation
29
38
  if (action === 'inject') {
30
39
  const { agentId, message } = body;
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { listPipelines, listWorkflows, startPipeline } from '@/lib/pipeline';
2
+ import { listPipelines, listPipelinesSummary, listWorkflows, startPipeline } from '@/lib/pipeline';
3
3
  import { writeFileSync, mkdirSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import YAML from 'yaml';
@@ -40,7 +40,21 @@ export async function GET(req: Request) {
40
40
  }
41
41
  }
42
42
 
43
- return NextResponse.json(listPipelines().sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
43
+ // Light summary by default — strips heavy node outputs / conversation
44
+ // bodies that the list UI doesn't render. Default limit 100 newest.
45
+ // Callers can opt into:
46
+ // ?full=1 — full Pipeline objects (rare; per-id endpoint is cheaper)
47
+ // ?limit=500 — more rows (server caps at 500)
48
+ // ?workflow=<name>— scope to one workflow (combine with high limit)
49
+ // ?before=<iso> — cursor for "Load more"
50
+ const wantFull = searchParams.get('full') === '1';
51
+ if (wantFull) {
52
+ return NextResponse.json(listPipelines().sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
53
+ }
54
+ const limit = Number(searchParams.get('limit')) || undefined;
55
+ const workflow = searchParams.get('workflow') || undefined;
56
+ const before = searchParams.get('before') || undefined;
57
+ return NextResponse.json(listPipelinesSummary({ limit, workflow, before }));
44
58
  }
45
59
 
46
60
  // POST /api/pipelines — start a pipeline or save a workflow
@@ -1,7 +1,36 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { listPlugins, getPlugin, installPlugin, uninstallPlugin, updatePluginConfig, listInstalledPlugins, getInstalledPlugin } from '@/lib/plugins/registry';
2
+ import { listPlugins, getPlugin, installPlugin, uninstallPlugin, updatePluginConfig, listInstalledPlugins, getInstalledPlugin, getSecretFieldNames } from '@/lib/plugins/registry';
3
3
  import { executePluginAction } from '@/lib/plugins/executor';
4
4
 
5
+ const SECRET_MASK = '••••••••';
6
+
7
+ function maskInstalledConfig<T extends { definition?: any; config?: Record<string, any> } | null | undefined>(inst: T): T {
8
+ if (!inst || !inst.config) return inst;
9
+ const secrets = getSecretFieldNames(inst.definition || null);
10
+ if (!secrets.length) return inst;
11
+ const masked = { ...inst.config };
12
+ for (const k of secrets) {
13
+ if (typeof masked[k] === 'string' && masked[k]) masked[k] = SECRET_MASK;
14
+ }
15
+ return { ...inst, config: masked } as T;
16
+ }
17
+
18
+ function restoreInstalledConfig(id: string, incoming: Record<string, any>): Record<string, any> {
19
+ const existing = getInstalledPlugin(id);
20
+ if (!existing) return incoming;
21
+ const secrets = getSecretFieldNames(existing.definition);
22
+ if (!secrets.length) return incoming;
23
+ const out = { ...incoming };
24
+ for (const k of secrets) {
25
+ if (out[k] === SECRET_MASK) {
26
+ const prev = existing.config?.[k];
27
+ if (typeof prev === 'string') out[k] = prev;
28
+ else delete out[k];
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
5
34
  // GET: list plugins or get plugin details
6
35
  export async function GET(req: Request) {
7
36
  const url = new URL(req.url);
@@ -13,17 +42,18 @@ export async function GET(req: Request) {
13
42
  const plugin = getPlugin(id);
14
43
  const inst = getInstalledPlugin(id);
15
44
  if (!plugin && !inst) return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
45
+ const masked = maskInstalledConfig(inst);
16
46
  return NextResponse.json({
17
- plugin: inst?.definition || plugin,
47
+ plugin: masked?.definition || plugin,
18
48
  installed: !!inst,
19
- config: inst?.config,
20
- instanceName: inst?.instanceName,
21
- source: inst?.source,
49
+ config: masked?.config,
50
+ instanceName: masked?.instanceName,
51
+ source: masked?.source,
22
52
  });
23
53
  }
24
54
 
25
55
  if (installed === 'true') {
26
- return NextResponse.json({ plugins: listInstalledPlugins() });
56
+ return NextResponse.json({ plugins: listInstalledPlugins().map(p => maskInstalledConfig(p)) });
27
57
  }
28
58
 
29
59
  return NextResponse.json({ plugins: listPlugins() });
@@ -37,7 +67,8 @@ export async function POST(req: Request) {
37
67
  switch (action) {
38
68
  case 'install': {
39
69
  if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
40
- const ok = installPlugin(id, config || {}, body.source ? { source: body.source, name: body.name } : undefined);
70
+ const merged = restoreInstalledConfig(id, config || {});
71
+ const ok = installPlugin(id, merged, body.source ? { source: body.source, name: body.name } : undefined);
41
72
  return NextResponse.json({ ok });
42
73
  }
43
74
  case 'create_instance': {
@@ -59,7 +90,8 @@ export async function POST(req: Request) {
59
90
  }
60
91
  case 'update_config': {
61
92
  if (!id || !config) return NextResponse.json({ error: 'id and config required' }, { status: 400 });
62
- const ok = updatePluginConfig(id, config);
93
+ const merged = restoreInstalledConfig(id, config);
94
+ const ok = updatePluginConfig(id, merged);
63
95
  return NextResponse.json({ ok });
64
96
  }
65
97
  case 'test': {
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { getFixedSession, setFixedSession, clearFixedSession, getAllFixedSessions } from '@/lib/project-sessions';
3
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { loadSettings } from '@/lib/settings';
4
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
4
5
  import { join } from 'node:path';
5
6
 
6
7
  // GET: get fixed session for a project, or all bindings
@@ -35,27 +36,66 @@ export async function POST(req: Request) {
35
36
  return NextResponse.json({ ok: true, projectPath, fixedSessionId });
36
37
  }
37
38
 
38
- /** Generate .forge/mcp.json in the project directory with workspace context baked in */
39
+ /**
40
+ * Generate the project's MCP config. Two locations:
41
+ * 1. <project>/.forge/mcp.json — Forge-internal copy, referenced by workspace
42
+ * orchestrator's --mcp-config flag when it launches agent terminals.
43
+ * 2. <project>/.mcp.json — picked up automatically by `claude` (and
44
+ * most other Claude Code-compatible CLIs) when launched at the project
45
+ * root, so users get Forge MCP tools without needing --mcp-config.
46
+ * Both files have identical content; (1) is authoritative — (2) is a mirror
47
+ * that's only written if it doesn't already exist OR is also a Forge-managed
48
+ * copy (to avoid stomping a user's hand-rolled .mcp.json).
49
+ */
39
50
  function ensureMcpConfig(projectPath: string): void {
40
51
  try {
41
52
  const forgeDir = join(projectPath, '.forge');
42
- const configPath = join(forgeDir, 'mcp.json');
53
+ const forgeConfigPath = join(forgeDir, 'mcp.json');
54
+ const rootConfigPath = join(projectPath, '.mcp.json');
43
55
  const mcpPort = Number(process.env.MCP_PORT) || 8406;
44
56
 
45
- // Resolve workspace + primary agent for this project
46
57
  let wsParam = '';
47
58
  try {
48
59
  const { findWorkspaceByProject } = require('@/lib/workspace');
49
60
  const ws = findWorkspaceByProject(projectPath);
50
- if (ws) {
51
- wsParam = `?workspaceId=${ws.id}`;
52
- }
61
+ if (ws) wsParam = `?workspaceId=${ws.id}`;
53
62
  } catch {}
54
63
 
55
- const config = { mcpServers: { forge: { type: 'sse', url: `http://localhost:${mcpPort}/sse${wsParam}` } } };
64
+ // Built-in Forge MCP server (agent bus, workspace ops)
65
+ const forgeEntry = { type: 'sse', url: `http://localhost:${mcpPort}/sse${wsParam}` };
66
+
67
+ // User-managed external MCP servers — chrome-devtools-mcp etc.
68
+ let userServers: Record<string, any> = {};
69
+ try {
70
+ const settings = loadSettings();
71
+ userServers = settings.mcpServers || {};
72
+ } catch {}
73
+
74
+ // Forge owns the `forge` key; users can shadow other keys here.
75
+ const config = {
76
+ mcpServers: { forge: forgeEntry, ...userServers },
77
+ };
78
+ const serialized = JSON.stringify(config, null, 2);
79
+ const forgeManagedKeys = new Set(Object.keys(config.mcpServers));
56
80
 
57
- // Always rewrite (workspace context may have changed)
58
81
  mkdirSync(forgeDir, { recursive: true });
59
- writeFileSync(configPath, JSON.stringify(config, null, 2));
82
+ writeFileSync(forgeConfigPath, serialized);
83
+
84
+ // Mirror to root .mcp.json so plain `claude` in the project picks up
85
+ // Forge's MCP servers. Two policies:
86
+ // - Don't CREATE the root file. Users who delete it want it gone;
87
+ // auto-recreating broke that expectation (and pulled global
88
+ // mcp servers into pipeline worktrees where they don't belong).
89
+ // - DO update an existing root file IF every server in it is one
90
+ // we manage — keeps the convenience for users who chose to have it,
91
+ // without stomping a hand-rolled one.
92
+ if (existsSync(rootConfigPath)) {
93
+ try {
94
+ const existing = JSON.parse(readFileSync(rootConfigPath, 'utf8'));
95
+ const existingKeys = Object.keys(existing?.mcpServers ?? {});
96
+ const allForgeManaged = existingKeys.length > 0 && existingKeys.every(k => forgeManagedKeys.has(k));
97
+ if (allForgeManaged) writeFileSync(rootConfigPath, serialized);
98
+ } catch { /* malformed user file — leave alone */ }
99
+ }
60
100
  } catch {}
61
101
  }
@@ -53,6 +53,19 @@ export async function PUT(req: Request) {
53
53
  }
54
54
  }
55
55
 
56
+ // Nested agent profile apiKeys are masked by loadSettingsMasked too —
57
+ // restore them here so a normal settings save doesn't overwrite a real
58
+ // key with '••••••••'. Empty string means user explicitly cleared it.
59
+ if (updated.agents) {
60
+ for (const [id, a] of Object.entries(updated.agents)) {
61
+ const newKey = (a as any)?.apiKey;
62
+ if (newKey === '••••••••') {
63
+ const existing = settings.agents?.[id]?.apiKey;
64
+ if (existing) (a as any).apiKey = existing;
65
+ }
66
+ }
67
+ }
68
+
56
69
  // Remove internal fields
57
70
  delete (updated as any)._secretStatus;
58
71