@aion0/forge 0.10.30 → 0.10.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/RELEASE_NOTES.md CHANGED
@@ -1,13 +1,11 @@
1
- # Forge v0.10.30
1
+ # Forge v0.10.32
2
2
 
3
3
  Released: 2026-06-02
4
4
 
5
- ## Changes since v0.10.29
5
+ ## Changes since v0.10.31
6
6
 
7
7
  ### Other
8
- - fix(chat): tune MAX_ITERATIONS 50→24
9
- - fix(chat): raise MAX_ITERATIONS 12→50 for complex multi-step tasks
10
- - fix(chat): raise MAX_ITERATIONS 6→12 + emit sentinel when cap hit
8
+ - fix(watch): builtin pollers return JSON + add get_task_status
11
9
 
12
10
 
13
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.29...v0.10.30
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.31...v0.10.32
@@ -163,50 +163,61 @@ const BUILTINS: Record<string, BuiltinHandler> = {
163
163
  }
164
164
 
165
165
  const pipeline = startPipeline(params.workflow, stringInput, { skills: skills.length ? skills : undefined });
166
- let line = `Pipeline started: ${pipeline.id} (workflow: ${params.workflow}, status: ${pipeline.status})`;
166
+ const fresh = getPipeline(pipeline.id) || pipeline;
167
+ const errors: string[] = [];
167
168
  if (pipeline.status === 'failed') {
168
- const fresh = getPipeline(pipeline.id) || pipeline;
169
- const errs: string[] = [];
170
169
  for (const [nid, n] of Object.entries(fresh.nodes || {})) {
171
- if ((n as any).error) errs.push(`${nid}: ${(n as any).error}`);
170
+ if ((n as any).error) errors.push(`${nid}: ${(n as any).error}`);
172
171
  }
173
- if (errs.length > 0) line += `\nFailure(s): ${errs.join(' | ').slice(0, 500)}`;
174
- } else if (pipeline.status === 'done') {
175
- // For for_each workflows, a "done" with zero iterations is the silent
176
- // failure mode (empty source). Warn the LLM explicitly so it doesn't
177
- // claim success.
178
- const fresh = getPipeline(pipeline.id) || pipeline;
172
+ }
173
+ let warning: string | undefined;
174
+ if (pipeline.status === 'done') {
179
175
  const forEach = (fresh as any).forEach;
180
176
  if (forEach && typeof forEach === 'object') {
181
177
  const iters = Array.isArray(forEach.iterations) ? forEach.iterations.length : 0;
182
178
  const total = typeof forEach.total === 'number' ? forEach.total : iters;
183
179
  if (total === 0) {
184
- line += '\n⚠ Pipeline finished with 0 iterations — likely empty source list. This is NOT a success; the work the user asked for did NOT happen. Re-check input fields (especially the one feeding for_each.source) and retry.';
180
+ warning = 'Pipeline finished with 0 iterations — likely empty source list. This is NOT a success; the work the user asked for did NOT happen. Re-check input fields (especially the one feeding for_each.source) and retry.';
185
181
  }
186
182
  }
187
- } else {
188
- line += '. Watch progress in the Pipelines view.';
189
183
  }
190
- return line;
184
+ return JSON.stringify({
185
+ ok: pipeline.status !== 'failed',
186
+ pipeline_id: pipeline.id,
187
+ workflow: params.workflow,
188
+ status: pipeline.status,
189
+ terminal: pipeline.status !== 'running',
190
+ ...(errors.length ? { errors: errors.join(' | ').slice(0, 500) } : {}),
191
+ ...(warning ? { warning } : {}),
192
+ hint: pipeline.status === 'running'
193
+ ? 'To wait for completion: start_watch poll="get_pipeline_status" poll_args={pipeline_id:"' + pipeline.id + '"} done_match={path:"status",equals:"done"} fail_path="status==failed". Do NOT poll get_pipeline_status in this conversation.'
194
+ : undefined,
195
+ });
191
196
  },
192
197
 
193
198
  // Query a pipeline run's status + per-node results by id (pairs with
194
- // trigger_pipeline's returned id). Mirrors the MCP get_pipeline_status tool.
199
+ // trigger_pipeline's returned id). Returns JSON so start_watch's
200
+ // done_match={path:"status",equals:"done"} can resolve — a string-shaped
201
+ // response leaves watches polling forever.
195
202
  get_pipeline_status: async (input) => {
196
203
  const params = (input as { pipeline_id?: string } | undefined) || {};
197
- if (!params.pipeline_id) return 'get_pipeline_status failed: pipeline_id is required (returned by trigger_pipeline).';
204
+ if (!params.pipeline_id) return JSON.stringify({ ok: false, error: 'pipeline_id is required (returned by trigger_pipeline)' });
198
205
  const { getPipeline } = await import('../pipeline');
199
206
  const pipeline = getPipeline(params.pipeline_id);
200
- if (!pipeline) return `Pipeline "${params.pipeline_id}" not found.`;
201
- const nodes = Object.entries(pipeline.nodes || {}).map(([id, n]) => {
202
- let line = ` ${id}: ${n.status}`;
203
- if (n.error) line += ` — ${n.error}`;
204
- for (const [k, v] of Object.entries(n.outputs || {})) {
205
- line += `\n ${k}: ${String(v).slice(0, 200)}`;
206
- }
207
- return line;
208
- }).join('\n');
209
- return `Pipeline ${pipeline.id} [${pipeline.status}] (${pipeline.workflowName})\n${nodes}`;
207
+ if (!pipeline) return JSON.stringify({ ok: false, error: `Pipeline "${params.pipeline_id}" not found` });
208
+ const nodes = Object.entries(pipeline.nodes || {}).map(([id, n]) => ({
209
+ id,
210
+ status: n.status,
211
+ ...(n.error ? { error: n.error } : {}),
212
+ outputs: Object.fromEntries(Object.entries(n.outputs || {}).map(([k, v]) => [k, String(v).slice(0, 500)])),
213
+ }));
214
+ return JSON.stringify({
215
+ id: pipeline.id,
216
+ status: pipeline.status,
217
+ terminal: pipeline.status !== 'running',
218
+ workflowName: pipeline.workflowName,
219
+ nodes,
220
+ });
210
221
  },
211
222
 
212
223
  // Surface Forge's local context (projects + agents + skills) so the chat
@@ -260,11 +271,11 @@ const BUILTINS: Record<string, BuiltinHandler> = {
260
271
  // caller can ask "what's the status of task <id>?" later — we don't block.
261
272
  dispatch_task: async (input) => {
262
273
  const params = (input as { project?: string; prompt?: string; agent?: string } | undefined) || {};
263
- if (!params.prompt) return 'dispatch_task failed: prompt is required';
274
+ if (!params.prompt) return JSON.stringify({ ok: false, error: 'prompt is required' });
264
275
  const { getProjectInfo, SCRATCH_PROJECT_NAME } = await import('../projects');
265
276
  const projectName = params.project?.trim() || SCRATCH_PROJECT_NAME;
266
277
  const project = getProjectInfo(projectName);
267
- if (!project) return `dispatch_task failed: project "${projectName}" not found`;
278
+ if (!project) return JSON.stringify({ ok: false, error: `project "${projectName}" not found` });
268
279
  const { createTask } = await import('../task-manager');
269
280
  const task = createTask({
270
281
  projectName: project.name,
@@ -273,7 +284,33 @@ const BUILTINS: Record<string, BuiltinHandler> = {
273
284
  conversationId: '',
274
285
  agent: params.agent || undefined,
275
286
  });
276
- return `Task dispatched: ${task.id} (project: ${project.name}, status: ${task.status}). Watch in the Tasks view.`;
287
+ return JSON.stringify({
288
+ ok: true,
289
+ task_id: task.id,
290
+ project: project.name,
291
+ status: task.status,
292
+ hint: 'To wait for completion: start_watch poll="get_task_status" poll_args={task_id:"' + task.id + '"} done_path="terminal". Do NOT poll get_task_status in this conversation.',
293
+ });
294
+ },
295
+
296
+ // Companion to dispatch_task — read a task's status + result. Returns JSON
297
+ // so start_watch can poll via done_path="terminal" or done_match
298
+ // {path:"status", equals:"done"}.
299
+ get_task_status: async (input) => {
300
+ const params = (input as { task_id?: string } | undefined) || {};
301
+ if (!params.task_id) return JSON.stringify({ ok: false, error: 'task_id is required (returned by dispatch_task)' });
302
+ const { getTask } = await import('../task-manager');
303
+ const task = getTask(params.task_id);
304
+ if (!task) return JSON.stringify({ ok: false, error: `Task "${params.task_id}" not found` });
305
+ return JSON.stringify({
306
+ id: task.id,
307
+ status: task.status,
308
+ terminal: task.status === 'done' || task.status === 'failed' || task.status === 'cancelled',
309
+ project: task.projectName,
310
+ ...(task.resultSummary ? { result_summary: String(task.resultSummary).slice(0, 1000) } : {}),
311
+ ...(task.error ? { error: String(task.error).slice(0, 500) } : {}),
312
+ ...(task.completedAt ? { completed_at: task.completedAt } : {}),
313
+ });
277
314
  },
278
315
 
279
316
  // List Forge's own help/documentation files so the chat agent can answer
@@ -315,7 +352,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
315
352
  },
316
353
  {
317
354
  name: 'trigger_pipeline',
318
- description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. If the pipeline fails immediately, the response includes the validation error so you can fix the inputs and retry.',
355
+ description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. On success returns JSON: {ok, pipeline_id, workflow, status, terminal, errors?, warning?, hint?}. If the run is still running, follow the hint call start_watch on get_pipeline_status and STOP polling in this conversation.',
319
356
  input_schema: {
320
357
  type: 'object',
321
358
  properties: {
@@ -337,7 +374,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
337
374
  },
338
375
  {
339
376
  name: 'get_pipeline_status',
340
- description: "Check a Forge pipeline run's live status + per-node results by id. Pass pipeline_id (returned by trigger_pipeline) to get the run's overall status + each node's status / error / outputs. Use whenever the user asks how a running or finished pipeline is doing.",
377
+ description: "Check a Forge pipeline run's live status + per-node results by id. Pass pipeline_id (returned by trigger_pipeline) to get a JSON object: {id, status: 'running'|'done'|'failed'|'cancelled', terminal: bool, workflowName, nodes: [{id, status, error?, outputs}]}. Use whenever the user asks how a running or finished pipeline is doing. For start_watch, pair this with done_match={path:\"status\",equals:\"done\"} (or done_path=\"terminal\" to fire on ANY terminal state) and fail_path=... per your needs.",
341
378
  input_schema: {
342
379
  type: 'object',
343
380
  properties: {
@@ -356,7 +393,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
356
393
  },
357
394
  {
358
395
  name: 'dispatch_task',
359
- description: 'Dispatch a one-shot background Claude task in a Forge project. Use for longer-running asks the user wants to fire-and-forget ("analyze X codebase and write findings to a file", "run the test suite and summarize failures"). Returns immediately with the task id; the task runs in the background and the user can check the Tasks view for output.',
396
+ description: 'Dispatch a one-shot background Claude task in a Forge project. Use for longer-running asks the user wants to fire-and-forget ("analyze X codebase and write findings to a file", "run the test suite and summarize failures"). Returns JSON: {ok, task_id, project, status, hint}. The task runs in the background; if the user wants to be notified on completion, follow the hint call start_watch on get_task_status and STOP polling in this conversation.',
360
397
  input_schema: {
361
398
  type: 'object',
362
399
  properties: {
@@ -376,6 +413,20 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
376
413
  required: ['prompt'],
377
414
  },
378
415
  },
416
+ {
417
+ name: 'get_task_status',
418
+ description: "Check a dispatched Forge task's status + result by id. Pass task_id (returned by dispatch_task). Returns JSON: {id, status: 'queued'|'running'|'done'|'failed'|'cancelled', terminal: bool, project, result_summary?, error?, completed_at?}. For start_watch, use done_path=\"terminal\" (fires on done/failed/cancelled) or done_match={path:\"status\",equals:\"done\"}.",
419
+ input_schema: {
420
+ type: 'object',
421
+ properties: {
422
+ task_id: {
423
+ type: 'string',
424
+ description: 'Task id (returned by dispatch_task).',
425
+ },
426
+ },
427
+ required: ['task_id'],
428
+ },
429
+ },
379
430
  {
380
431
  name: 'list_help_docs',
381
432
  description: "List Forge's own documentation files. Call this FIRST whenever the user asks how Forge itself works — its features, settings/config, setup, or troubleshooting (e.g. pipelines, schedules, connectors, telegram, tunnel, workspace/smiths, skills, crafts, usage/cost, agents/models). Returns doc filenames; then read the relevant one(s) with read_help_doc and answer from their content. No arguments.",
@@ -35,12 +35,14 @@ export function buildStartWatchTool(sessionId: string | null): StartWatchTool {
35
35
  const def: BuiltinToolDef = {
36
36
  name: 'start_watch',
37
37
  description:
38
- 'Register a BACKGROUND WATCH that polls a tool until done, then posts the result back here — for long-running jobs you just kicked off (a Jenkins build, a test run, a device upgrade). Use this INSTEAD of polling in conversation: call the trigger tool, then call start_watch and STOP — Forge polls in the background and a completion message arrives in this chat. ' +
39
- 'Pick `poll` = the read tool that reports status (e.g. "jenkins.get_build") and `poll_args` to call it with (e.g. the build number you predicted via get_next_build_number). Give a done condition: `done_match` {path, equals} on the poll result (e.g. path "result" equals "SUCCESS"), or `done_path` (a result path that becomes truthy). You usually already saw the poll tool\'s output once, so you know the right field. Optional `fail_path` (truthy = failed). Tune `interval_sec`/`timeout_sec` to the job (build 60s / 1800s).',
38
+ 'Register a BACKGROUND WATCH that polls a tool until done, then posts the result back here — for long-running jobs you just kicked off (a Forge pipeline, a Jenkins build, a test run, a device upgrade). Use this INSTEAD of polling in conversation: call the trigger tool, then call start_watch and STOP — Forge polls in the background and a completion message arrives in this chat. ' +
39
+ 'Pick `poll` = the read tool that reports status. Two forms accepted: (a) connector tool "<connector>.<tool>" e.g. "jenkins.get_build", "gitlab.get_pipeline"; (b) BARE builtin name "get_pipeline_status" (pair with poll_args={pipeline_id} and done_match={path:"status",equals:"done"}) or "get_task_status" (pair with poll_args={task_id} and done_path="terminal"). Set `poll_args` to call it with (e.g. the build number you predicted via get_next_build_number). ' +
40
+ 'Give a done condition: `done_match` {path, equals} on the poll result (e.g. path "result" equals "SUCCESS"), or `done_path` (a result path that becomes truthy). You usually already saw the poll tool\'s output once, so you know the right field. Optional `fail_path` (truthy = failed). ' +
41
+ 'If the poll tool returns NON-JSON text (e.g. some shell-protocol tools), the result is wrapped as {_raw: "...full output..."}, so use done_match={path:"_raw",contains:"DONE"} to grep markers in the output. Tune `interval_sec`/`timeout_sec` to the job (pipeline ≈ 30s / 1800s, build ≈ 60s / 1800s).',
40
42
  input_schema: {
41
43
  type: 'object',
42
44
  properties: {
43
- poll: { type: 'string', description: 'Tool to poll, "<connector>.<tool>" e.g. "jenkins.get_build". Must be a read/status tool.' },
45
+ poll: { type: 'string', description: 'Tool to poll. Either "<connector>.<tool>" e.g. "jenkins.get_build", OR a bare builtin name — "get_pipeline_status" (Forge pipelines) or "get_task_status" (dispatched tasks). Must be a read/status tool.' },
44
46
  poll_args: { type: 'object', description: 'Args to call the poll tool with each tick, e.g. {"job_path":"job/foo","build_number":18}. Concrete values, not templates.' },
45
47
  done_match: {
46
48
  type: 'object',
@@ -68,9 +70,16 @@ export function buildStartWatchTool(sessionId: string | null): StartWatchTool {
68
70
  const a = (input ?? {}) as Record<string, any>;
69
71
  const poll = String(a.poll || '').trim();
70
72
  const dot = poll.indexOf('.');
71
- if (dot < 1) return JSON.stringify({ ok: false, error: 'poll must be "<connector>.<tool>", e.g. jenkins.get_build' });
72
- const connectorId = poll.slice(0, dot);
73
- const pollTool = poll.slice(dot + 1);
73
+ // Two forms accepted:
74
+ // "connector.tool" → connector_id="connector", poll_tool="tool"
75
+ // "tool" → builtin (e.g. get_pipeline_status), connector_id=""
76
+ // The watch-runner detects empty connector_id and dispatches as a
77
+ // bare builtin name instead of "connector.tool".
78
+ const connectorId = dot >= 1 ? poll.slice(0, dot) : '';
79
+ const pollTool = dot >= 1 ? poll.slice(dot + 1) : poll;
80
+ if (!pollTool) {
81
+ return JSON.stringify({ ok: false, error: 'poll is required, e.g. "jenkins.get_build" or builtin "get_pipeline_status"' });
82
+ }
74
83
 
75
84
  const doneMatch = a.done_match && typeof a.done_match === 'object' && a.done_match.path
76
85
  ? { path: String(a.done_match.path), ...(a.done_match.equals != null ? { equals: String(a.done_match.equals) } : {}), ...(a.done_match.contains != null ? { contains: String(a.done_match.contains) } : {}) }
@@ -83,8 +92,8 @@ export function buildStartWatchTool(sessionId: string | null): StartWatchTool {
83
92
  return JSON.stringify({ ok: false, error: `active watch limit reached (${MAX_ACTIVE_WATCHES})` });
84
93
  }
85
94
 
86
- const hint = ['build_number', 'host', 'ip', 'lab', 'id', 'name'].map((k) => a.poll_args?.[k]).find((v) => v != null && v !== '');
87
- const label = `${connectorId}.${pollTool}${hint != null ? ` ${hint}` : ''}`;
95
+ const hint = ['build_number', 'host', 'ip', 'lab', 'id', 'name', 'pipeline_id'].map((k) => a.poll_args?.[k]).find((v) => v != null && v !== '');
96
+ const label = `${connectorId ? `${connectorId}.${pollTool}` : pollTool}${hint != null ? ` ${hint}` : ''}`;
88
97
 
89
98
  const w = createWatch({
90
99
  session_id: sessionId,
@@ -93,7 +93,10 @@ export function startWatchRunner(hooks: WatchRunnerHooks = {}): void {
93
93
  // preamble — the body comes back as raw JSON so done_path/done_match can
94
94
  // see it. (Without this, every http-protocol watch would silently never
95
95
  // hit its done condition, e.g. jenkins.get_build never resolving.)
96
- res = await dispatchTool({ id: `watch-${w.id}-${w.polls}`, name: `${w.connector_id}.${w.poll_tool}`, input: w.poll_args }, { noTruncation: true } as any);
96
+ // Empty connector_id builtin (e.g. get_pipeline_status); dispatch
97
+ // bare name so dispatchTool routes to the global BUILTINS table.
98
+ const dispatchName = w.connector_id ? `${w.connector_id}.${w.poll_tool}` : w.poll_tool;
99
+ res = await dispatchTool({ id: `watch-${w.id}-${w.polls}`, name: dispatchName, input: w.poll_args }, { noTruncation: true } as any);
97
100
  } catch (e) {
98
101
  res = { content: String(e), is_error: true };
99
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.30",
3
+ "version": "0.10.32",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {