@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
package/cli/chat.ts ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * `forge chat` — interactive chat from the terminal.
3
+ *
4
+ * Talks loopback-only to chat-standalone (default 127.0.0.1:8408).
5
+ * For a remote Forge instance, set FORGE_CHAT_URL to a reachable URL
6
+ * (typically the host's chat port over a tunnel).
7
+ *
8
+ * Streaming model:
9
+ * 1. open SSE on /api/sessions/<id>/events
10
+ * 2. wait for the initial `: subscribed` comment so we don't race
11
+ * the upcoming agent events
12
+ * 3. POST /api/sessions/<id>/messages → 202
13
+ * 4. consume agent events: text_delta → stdout, tool_use/result →
14
+ * grey aside, turn_done → exit, error → stderr
15
+ *
16
+ * Active session id is persisted in <dataDir>/cli-chat-session.txt so
17
+ * `forge chat "..."` repeated calls continue the same conversation.
18
+ */
19
+
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { homedir } from 'node:os';
23
+
24
+ const CHAT_PORT = process.env.CHAT_PORT || '8408';
25
+ const BASE = (process.env.FORGE_CHAT_URL || `http://127.0.0.1:${CHAT_PORT}`).replace(/\/+$/, '');
26
+
27
+ function dataDir(): string {
28
+ return process.env.FORGE_DATA_DIR || join(homedir(), '.forge', 'data');
29
+ }
30
+ function activeFile(): string { return join(dataDir(), 'cli-chat-session.txt'); }
31
+
32
+ function loadActive(): string | null {
33
+ try {
34
+ const f = activeFile();
35
+ if (!existsSync(f)) return null;
36
+ const v = readFileSync(f, 'utf-8').trim();
37
+ return v || null;
38
+ } catch { return null; }
39
+ }
40
+ function saveActive(id: string): void {
41
+ try {
42
+ mkdirSync(dataDir(), { recursive: true });
43
+ writeFileSync(activeFile(), id);
44
+ } catch {}
45
+ }
46
+ function clearActive(): void {
47
+ try { rmSync(activeFile(), { force: true }); } catch {}
48
+ }
49
+
50
+ async function api(path: string, init?: RequestInit): Promise<any> {
51
+ let res: Response;
52
+ try { res = await fetch(BASE + path, init); }
53
+ catch (e) { throw new Error(`chat service unreachable on ${BASE}: ${(e as Error).message}`); }
54
+ if (!res.ok && res.status !== 202) {
55
+ throw new Error(`HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`);
56
+ }
57
+ const ct = res.headers.get('content-type') || '';
58
+ if (!ct.includes('application/json')) return null;
59
+ return res.json();
60
+ }
61
+
62
+ interface AgentEvent { type: string; data?: any; message_id?: string }
63
+
64
+ const isTty = !!process.stdout.isTTY;
65
+ const dim = (s: string) => isTty ? `\x1b[2m${s}\x1b[0m` : s;
66
+ const red = (s: string) => isTty ? `\x1b[31m${s}\x1b[0m` : s;
67
+ const cyan = (s: string) => isTty ? `\x1b[36m${s}\x1b[0m` : s;
68
+
69
+ async function openSse(sessionId: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {
70
+ const res = await fetch(`${BASE}/api/sessions/${encodeURIComponent(sessionId)}/events`, {
71
+ headers: { accept: 'text/event-stream' },
72
+ });
73
+ if (!res.ok) throw new Error(`SSE open failed: HTTP ${res.status}`);
74
+ if (!res.body) throw new Error('SSE response has no body');
75
+ const reader = res.body.getReader();
76
+ // Wait for the initial `: subscribed` comment so the server-side
77
+ // subscription is registered before the caller fires the POST.
78
+ await reader.read();
79
+ return reader;
80
+ }
81
+
82
+ async function consumeSse(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
83
+ const decoder = new TextDecoder();
84
+ let buf = '';
85
+ let inText = false;
86
+
87
+ while (true) {
88
+ const { value, done } = await reader.read();
89
+ if (done) return;
90
+ buf += decoder.decode(value, { stream: true });
91
+
92
+ let idx: number;
93
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
94
+ const frame = buf.slice(0, idx);
95
+ buf = buf.slice(idx + 2);
96
+
97
+ const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
98
+ if (!dataLine) continue;
99
+ let evt: AgentEvent;
100
+ try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; }
101
+
102
+ switch (evt.type) {
103
+ case 'text_delta': {
104
+ inText = true;
105
+ process.stdout.write(String(evt.data?.delta || ''));
106
+ break;
107
+ }
108
+ case 'tool_use': {
109
+ if (inText) { process.stdout.write('\n'); inText = false; }
110
+ const name = String(evt.data?.name || '?');
111
+ let inp = '';
112
+ try { inp = JSON.stringify(evt.data?.input ?? {}); } catch { inp = ''; }
113
+ if (inp.length > 60) inp = inp.slice(0, 60) + '…';
114
+ process.stdout.write(dim(` → ${name}(${inp}) …\n`));
115
+ break;
116
+ }
117
+ case 'tool_result': {
118
+ const name = String(evt.data?.name || '?');
119
+ const r = evt.data?.result;
120
+ const text = typeof r === 'string' ? r : (r?.content ?? JSON.stringify(r ?? ''));
121
+ const head = String(text).split('\n', 1)[0] || '';
122
+ const short = head.length > 80 ? head.slice(0, 80) + '…' : head;
123
+ process.stdout.write(dim(` ← ${name}: ${short}\n`));
124
+ break;
125
+ }
126
+ case 'turn_done': {
127
+ if (inText) process.stdout.write('\n');
128
+ try { await reader.cancel(); } catch {}
129
+ return;
130
+ }
131
+ case 'error': {
132
+ if (inText) process.stdout.write('\n');
133
+ process.stderr.write(red(`error: ${String(evt.data?.error || 'unknown')}\n`));
134
+ try { await reader.cancel(); } catch {}
135
+ return;
136
+ }
137
+ // 'message_saved' is authoritative state used by GUI clients;
138
+ // CLI already streamed the text, so we ignore it.
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ async function createSession(): Promise<string> {
145
+ const r = await api('/api/sessions', {
146
+ method: 'POST',
147
+ headers: { 'content-type': 'application/json' },
148
+ body: JSON.stringify({ title: 'CLI chat' }),
149
+ });
150
+ const id = r?.session?.id;
151
+ if (!id) throw new Error('create session: no id in response');
152
+ return id;
153
+ }
154
+
155
+ export async function chatCommand(args: string[]): Promise<void> {
156
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
157
+ return printHelp();
158
+ }
159
+
160
+ const sub = args[0];
161
+ if (sub === 'sessions' || sub === 'ls') return cmdSessions();
162
+ if (sub === 'history' || sub === 'h') return cmdHistory(args[1]);
163
+ if (sub === 'watch') return cmdWatch(args[1]);
164
+ if (sub === 'new') return cmdNew();
165
+ if (sub === 'rm') return cmdRm(args[1]);
166
+ if (sub === 'use') return cmdUse(args[1]);
167
+
168
+ // Otherwise treat the args as a message to send.
169
+ let forceNew = false;
170
+ let explicit: string | null = null;
171
+ const parts: string[] = [];
172
+ for (let i = 0; i < args.length; i++) {
173
+ const a = args[i]!;
174
+ if (a === '-n' || a === '--new') { forceNew = true; continue; }
175
+ if (a === '-s' || a === '--session') { explicit = args[++i] || null; continue; }
176
+ parts.push(a);
177
+ }
178
+ const text = parts.join(' ').trim();
179
+ if (!text) {
180
+ console.error('No message provided. Try: forge chat "your message"');
181
+ process.exit(1);
182
+ }
183
+
184
+ let sessionId = explicit;
185
+ if (!sessionId && !forceNew) sessionId = loadActive();
186
+ if (!sessionId) {
187
+ sessionId = await createSession();
188
+ process.stderr.write(dim(`(new session ${sessionId.slice(0, 8)})\n`));
189
+ }
190
+ saveActive(sessionId);
191
+
192
+ // Open SSE first, wait for subscribe, then POST so we don't miss events.
193
+ const reader = await openSse(sessionId);
194
+ await api(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
195
+ method: 'POST',
196
+ headers: { 'content-type': 'application/json' },
197
+ body: JSON.stringify({ text }),
198
+ });
199
+ await consumeSse(reader);
200
+ }
201
+
202
+ async function cmdSessions(): Promise<void> {
203
+ const j = await api('/api/sessions?limit=50');
204
+ if (!j?.sessions?.length) { console.log('(no sessions)'); return; }
205
+ const active = loadActive();
206
+ for (const s of j.sessions) {
207
+ const marker = s.id === active ? '*' : ' ';
208
+ const ts = new Date(s.updated_at || s.created_at).toISOString().slice(0, 19).replace('T', ' ');
209
+ console.log(`${marker} ${cyan(s.id.slice(0, 8))} ${dim(ts)} ${s.title || '(untitled)'}`);
210
+ }
211
+ }
212
+
213
+ async function cmdHistory(id?: string): Promise<void> {
214
+ const sessionId = id || loadActive();
215
+ if (!sessionId) { console.error('No session. Pick one with: forge chat sessions'); process.exit(1); }
216
+ const j = await api(`/api/sessions/${encodeURIComponent(sessionId)}`);
217
+ if (!j?.messages?.length) { console.log('(empty)'); return; }
218
+ for (const m of j.messages) {
219
+ const role = m.role === 'user' ? 'U' : 'A';
220
+ for (const b of m.blocks || []) {
221
+ if (b.type === 'text' && b.text) {
222
+ console.log(`[${role}] ${b.text}`);
223
+ } else if (b.type === 'tool_use') {
224
+ let inp = '';
225
+ try { inp = JSON.stringify(b.input ?? {}); } catch {}
226
+ if (inp.length > 80) inp = inp.slice(0, 80) + '…';
227
+ console.log(dim(` → ${b.name}(${inp})`));
228
+ } else if (b.type === 'tool_result') {
229
+ const head = String(b.content || '').split('\n', 1)[0] || '';
230
+ console.log(dim(` ← ${head.slice(0, 100)}`));
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ async function cmdWatch(id?: string): Promise<void> {
237
+ const sessionId = id || loadActive();
238
+ if (!sessionId) { console.error('No session. Pick one with: forge chat sessions'); process.exit(1); }
239
+ process.stderr.write(dim(`watching ${sessionId} (Ctrl+C to exit)\n`));
240
+ const reader = await openSse(sessionId);
241
+ await consumeSse(reader);
242
+ }
243
+
244
+ async function cmdNew(): Promise<void> {
245
+ const id = await createSession();
246
+ saveActive(id);
247
+ console.log(id);
248
+ }
249
+
250
+ async function cmdRm(id?: string): Promise<void> {
251
+ if (!id) { console.error('Usage: forge chat rm <id>'); process.exit(1); }
252
+ await api(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
253
+ if (loadActive() === id) clearActive();
254
+ console.log('deleted');
255
+ }
256
+
257
+ async function cmdUse(id?: string): Promise<void> {
258
+ if (!id) { console.error('Usage: forge chat use <id>'); process.exit(1); }
259
+ // Verify it exists
260
+ await api(`/api/sessions/${encodeURIComponent(id)}`);
261
+ saveActive(id);
262
+ console.log(`active: ${id}`);
263
+ }
264
+
265
+ function printHelp(): void {
266
+ console.log(`forge chat — chat with your Forge agent from the terminal
267
+
268
+ Usage:
269
+ forge chat "<prompt>" Send to active session (creates one if none).
270
+ forge chat -n "<prompt>" Force a fresh session for this turn.
271
+ forge chat -s <id> "<prompt>" Send to a specific session.
272
+
273
+ forge chat sessions List recent sessions ('*' = active).
274
+ forge chat history [<id>] Print message history (default: active).
275
+ forge chat watch [<id>] Subscribe to the live event stream.
276
+ forge chat new Create a fresh session and set as active.
277
+ forge chat use <id> Set <id> as the active session.
278
+ forge chat rm <id> Delete a session.
279
+
280
+ Env:
281
+ FORGE_CHAT_URL=http://127.0.0.1:8408 chat-standalone endpoint
282
+ CHAT_PORT=8408 shorthand for the port`);
283
+ }
package/cli/jobs.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `forge jobs` — list / inspect / fire / toggle / delete Jobs.
3
+ * Talks to Forge web (default 8403) via the same MW_URL base as the rest
4
+ * of the CLI. Auth is required (admin token) — surface 401 from the API.
5
+ */
6
+
7
+ interface Job {
8
+ id: string;
9
+ name: string;
10
+ enabled: boolean;
11
+ schedule_interval_minutes: number;
12
+ source_connector: string;
13
+ source_tool: string;
14
+ dispatch_type: 'pipeline' | 'chat';
15
+ last_run_at: string | null;
16
+ next_run_at: string | null;
17
+ }
18
+
19
+ interface JobRun {
20
+ id: string;
21
+ job_id: string;
22
+ started_at: string;
23
+ finished_at: string | null;
24
+ status: 'running' | 'ok' | 'error';
25
+ items_seen: number;
26
+ items_new: number;
27
+ items_dispatched: number;
28
+ error: string | null;
29
+ trigger: 'schedule' | 'manual';
30
+ }
31
+
32
+ const isTty = !!process.stdout.isTTY;
33
+ const dim = (s: string) => isTty ? `\x1b[2m${s}\x1b[0m` : s;
34
+ const red = (s: string) => isTty ? `\x1b[31m${s}\x1b[0m` : s;
35
+ const grn = (s: string) => isTty ? `\x1b[32m${s}\x1b[0m` : s;
36
+ const cyan = (s: string) => isTty ? `\x1b[36m${s}\x1b[0m` : s;
37
+
38
+ function getBase(): string {
39
+ const portArgIdx = process.argv.findIndex(a => a === '--port');
40
+ const port = portArgIdx >= 0 ? process.argv[portArgIdx + 1] : undefined;
41
+ return process.env.MW_URL || `http://localhost:${port || '8403'}`;
42
+ }
43
+
44
+ async function api(path: string, init?: RequestInit): Promise<any> {
45
+ const base = getBase();
46
+ let res: Response;
47
+ try { res = await fetch(base + path, init); }
48
+ catch (e) { throw new Error(`forge unreachable on ${base}: ${(e as Error).message}`); }
49
+ if (!res.ok && res.status !== 202) {
50
+ throw new Error(`HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`);
51
+ }
52
+ return res.status === 204 ? null : res.json();
53
+ }
54
+
55
+ function statusBadge(s: string): string {
56
+ if (s === 'ok') return grn('✓ ok');
57
+ if (s === 'error') return red('✗ error');
58
+ if (s === 'running') return cyan('… running');
59
+ return s;
60
+ }
61
+
62
+ export async function jobsCommand(args: string[]): Promise<void> {
63
+ const sub = args[0] || 'list';
64
+ switch (sub) {
65
+ case 'list':
66
+ case 'ls':
67
+ return cmdList();
68
+ case 'show': return cmdShow(args[1]);
69
+ case 'runs': return cmdRuns(args[1]);
70
+ case 'run': return cmdRun(args[1], ...args.slice(2));
71
+ case 'dispatches':
72
+ case 'd': return cmdDispatches(args[1], args[2]);
73
+ case 'enable':
74
+ case 'disable': return cmdToggle(args[1], sub === 'enable');
75
+ case 'rm': return cmdRm(args[1]);
76
+ case 'reset':return cmdReset(args[1]);
77
+ case '--help':
78
+ case '-h':
79
+ default:
80
+ return printHelp();
81
+ }
82
+ }
83
+
84
+ async function cmdList(): Promise<void> {
85
+ const j = await api('/api/jobs');
86
+ if (!j?.jobs?.length) { console.log('(no jobs)'); return; }
87
+ for (const job of j.jobs as Job[]) {
88
+ const status = job.enabled ? grn('●') : dim('○');
89
+ const next = job.next_run_at ? dim(`next ${job.next_run_at}`) : dim('next: now');
90
+ const last = job.last_run_at ? dim(`last ${job.last_run_at}`) : dim('never run');
91
+ console.log(`${status} ${cyan(job.id)} ${job.name}`);
92
+ console.log(` ${dim(`${job.source_connector}.${job.source_tool}`)} → ${job.dispatch_type} · every ${job.schedule_interval_minutes}m`);
93
+ console.log(` ${last} ${next}`);
94
+ }
95
+ }
96
+
97
+ async function cmdShow(id?: string): Promise<void> {
98
+ if (!id) { console.error('Usage: forge jobs show <id>'); process.exit(1); }
99
+ const j = await api(`/api/jobs/${encodeURIComponent(id)}`);
100
+ console.log(JSON.stringify(j, null, 2));
101
+ }
102
+
103
+ async function cmdRuns(id?: string): Promise<void> {
104
+ if (!id) { console.error('Usage: forge jobs runs <id>'); process.exit(1); }
105
+ const j = await api(`/api/jobs/${encodeURIComponent(id)}/runs?limit=20`);
106
+ if (!j?.runs?.length) { console.log('(no runs)'); return; }
107
+ for (const r of j.runs as JobRun[]) {
108
+ const dur = r.finished_at && r.started_at
109
+ ? `${Math.round((new Date(r.finished_at).getTime() - new Date(r.started_at).getTime()) / 100) / 10}s`
110
+ : '…';
111
+ console.log(`${cyan(r.id)} ${r.started_at} ${statusBadge(r.status)} ${dim(`(${dur})`)}`);
112
+ console.log(` seen=${r.items_seen} new=${r.items_new} dispatched=${r.items_dispatched}${r.error ? ' ' + red(r.error.slice(0, 100)) : ''}`);
113
+ }
114
+ }
115
+
116
+ async function cmdDispatches(jobId?: string, runId?: string): Promise<void> {
117
+ if (!jobId || !runId) { console.error('Usage: forge jobs dispatches <jobId> <runId>'); process.exit(1); }
118
+ const j = await api(`/api/jobs/${encodeURIComponent(jobId)}/runs/${encodeURIComponent(runId)}`);
119
+ console.log(`run ${cyan(j.run.id)} ${statusBadge(j.run.status)} seen=${j.run.items_seen} new=${j.run.items_new} dispatched=${j.run.items_dispatched}`);
120
+ if (j.run.error) console.log(red(' error: ' + j.run.error));
121
+ if (!j.dispatches?.length) { console.log(dim(' (no dispatches)')); return; }
122
+ for (const d of j.dispatches) {
123
+ const label = d.status === 'dispatched' ? grn('✓') : red('✗');
124
+ console.log(` ${label} ${cyan(d.item_key)} ${dim('→')} ${d.dispatch_type}:${d.dispatch_target_id || '(none)'}`);
125
+ if (d.item_preview) console.log(dim(' ' + (d.item_preview.slice(0, 100))));
126
+ if (d.error) console.log(red(' error: ' + d.error.slice(0, 200)));
127
+ }
128
+ }
129
+
130
+ async function cmdRun(id?: string, ...rest: string[]): Promise<void> {
131
+ if (!id) { console.error('Usage: forge jobs run <id> [--force]'); process.exit(1); }
132
+ const force = rest.includes('--force') || rest.includes('-f');
133
+ const r = await api(`/api/jobs/${encodeURIComponent(id)}/run${force ? '?reset_dedup=1' : ''}`, { method: 'POST' });
134
+ console.log(`accepted — run_id=${r.run_id}` + (r.dedup_reset ? ` (dedup reset, removed ${r.removed_dedup_keys} keys)` : ''));
135
+ console.log(dim(`watch: forge jobs runs ${id}`));
136
+ }
137
+
138
+ async function cmdToggle(id: string | undefined, enabled: boolean): Promise<void> {
139
+ if (!id) { console.error(`Usage: forge jobs ${enabled ? 'enable' : 'disable'} <id>`); process.exit(1); }
140
+ await api(`/api/jobs/${encodeURIComponent(id)}`, {
141
+ method: 'PATCH',
142
+ headers: { 'content-type': 'application/json' },
143
+ body: JSON.stringify({ enabled }),
144
+ });
145
+ console.log(`${enabled ? 'enabled' : 'disabled'} ${id}`);
146
+ }
147
+
148
+ async function cmdRm(id?: string): Promise<void> {
149
+ if (!id) { console.error('Usage: forge jobs rm <id>'); process.exit(1); }
150
+ await api(`/api/jobs/${encodeURIComponent(id)}`, { method: 'DELETE' });
151
+ console.log('deleted');
152
+ }
153
+
154
+ async function cmdReset(id?: string): Promise<void> {
155
+ if (!id) { console.error('Usage: forge jobs reset <id>'); process.exit(1); }
156
+ const r = await api(`/api/jobs/${encodeURIComponent(id)}/reset_dedup`, { method: 'POST' });
157
+ console.log(`dedup wiped — removed ${r.removed} keys`);
158
+ }
159
+
160
+ function printHelp(): void {
161
+ console.log(`forge jobs — scheduled connector polls
162
+
163
+ Usage:
164
+ forge jobs list jobs
165
+ forge jobs show <id> full JSON
166
+ forge jobs runs <id> recent runs (summary line per tick)
167
+ forge jobs dispatches <id> <run> per-item dispatches for one run (target ids)
168
+ forge jobs run <id> [--force] fire now (manual trigger; --force resets dedup first)
169
+ forge jobs enable <id> re-enable
170
+ forge jobs disable <id> stop scheduling
171
+ forge jobs reset <id> wipe dedup state (re-process everything next tick)
172
+ forge jobs rm <id> delete
173
+
174
+ To create a job, use the Forge extension Jobs tab or POST /api/jobs directly
175
+ (the YAML-vs-form authoring story lives in the extension).`);
176
+ }
package/cli/mw.ts CHANGED
@@ -87,6 +87,27 @@ async function main() {
87
87
  }
88
88
 
89
89
  switch (cmd) {
90
+ case 'chat':
91
+ case 'c': {
92
+ const { chatCommand } = await import('./chat');
93
+ await chatCommand(args);
94
+ break;
95
+ }
96
+
97
+ case 'jobs':
98
+ case 'j': {
99
+ const { jobsCommand } = await import('./jobs');
100
+ await jobsCommand(args);
101
+ break;
102
+ }
103
+
104
+ case 'worktree':
105
+ case 'wt': {
106
+ const { worktreeCommand } = await import('./worktree');
107
+ await worktreeCommand(args);
108
+ break;
109
+ }
110
+
90
111
  case 'task':
91
112
  case 't': {
92
113
  // Parse --new flag to force a fresh session
@@ -544,6 +565,12 @@ Usage:
544
565
  forge server dev Start in dev mode
545
566
  forge server rebuild Force rebuild
546
567
 
568
+ forge chat "<prompt>" Chat with Forge (streams reply)
569
+ forge chat sessions List chat sessions
570
+ forge jobs List scheduled connector-poll jobs
571
+ forge jobs run <id> Manually fire a job tick
572
+ forge worktree List leftover pipeline worktrees
573
+ forge worktree clean Remove leftover pipeline worktrees
547
574
  forge task <project> <prompt> Submit a task
548
575
  forge tasks [status] List tasks
549
576
  forge watch <id> Live stream output
@@ -568,7 +595,7 @@ Options for 'forge server start':
568
595
  --background Run in background
569
596
  --reset-terminal Kill terminal server on start
570
597
 
571
- Shortcuts: t=task, ls=tasks, w=watch, s=status, l=log, f=flows, p=projects, pw=password`);
598
+ Shortcuts: c=chat, j=jobs, wt=worktree, t=task, ls=tasks, w=watch, s=status, l=log, f=flows, p=projects, pw=password`);
572
599
  }
573
600
  }
574
601