@aion0/forge 0.10.23 → 0.10.26

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,239 @@
1
+ /**
2
+ * Connector test runner — extracted from the /api/connectors/:id/test
3
+ * route so both that HTTP endpoint AND server-side callers (e.g. the
4
+ * Login Status panel) can run probes without a self-fetch dance.
5
+ *
6
+ * Self-fetching the route from inside the server hits the auth
7
+ * middleware (no session cookie → 401), making every probe look
8
+ * "unauthorized" regardless of the real connector state. Direct
9
+ * function calls bypass that.
10
+ */
11
+
12
+ import {
13
+ getConnector,
14
+ getConnectorEntries,
15
+ getInstalledConnector,
16
+ } from './registry';
17
+ import { expandSettingsTokens, expandAllTokens } from '../plugins/templates';
18
+ import { bridgeRpc } from '../chat/bridge-client';
19
+ import { applyAuth } from '../chat/protocols/http';
20
+ import type { ConnectorDefinition, ConnectorTest, HttpRequestSpec } from './types';
21
+
22
+ const DEFAULT_TIMEOUT_MS = 15_000;
23
+ const MAX_BODY_PREVIEW = 1024;
24
+
25
+ export interface TestResult {
26
+ ok: boolean;
27
+ status?: number;
28
+ message?: string;
29
+ error?: string;
30
+ duration_ms?: number;
31
+ body_preview?: string;
32
+ /** Final landed URL for browser probes — helps diagnose redirects. */
33
+ url?: string;
34
+ }
35
+
36
+ function expandString(s: string, settings: Record<string, unknown>): string {
37
+ return expandAllTokens(s, settings as Record<string, any>, {});
38
+ }
39
+
40
+ function buildUrl(spec: HttpRequestSpec, settings: Record<string, unknown>): string {
41
+ let url = expandString(spec.url, settings);
42
+ if (spec.query) {
43
+ const u = new URL(url);
44
+ for (const [k, raw] of Object.entries(spec.query)) {
45
+ u.searchParams.set(k, expandString(String(raw), settings));
46
+ }
47
+ url = u.toString();
48
+ }
49
+ return url;
50
+ }
51
+
52
+ function buildHeaders(spec: HttpRequestSpec, settings: Record<string, unknown>): Headers {
53
+ const h = new Headers();
54
+ if (spec.headers) {
55
+ for (const [k, raw] of Object.entries(spec.headers)) {
56
+ h.set(k, expandString(String(raw), settings));
57
+ }
58
+ }
59
+ return h;
60
+ }
61
+
62
+ function buildBody(
63
+ spec: HttpRequestSpec,
64
+ settings: Record<string, unknown>,
65
+ ): { body?: string; contentType?: string } {
66
+ if (spec.body == null) return {};
67
+ if (typeof spec.body === 'string') {
68
+ return { body: expandString(spec.body, settings) };
69
+ }
70
+ const out: Record<string, unknown> = {};
71
+ for (const [k, v] of Object.entries(spec.body)) {
72
+ out[k] = typeof v === 'string' ? expandString(v, settings) : v;
73
+ }
74
+ return { body: JSON.stringify(out), contentType: 'application/json' };
75
+ }
76
+
77
+ function renderTemplate(template: string, body: unknown): string {
78
+ return template.replace(/\{\{([^{}]+)\}\}/g, (_match, expr) => {
79
+ const path = String(expr).trim().split('.').filter(Boolean);
80
+ let cur: any = body;
81
+ for (const p of path) {
82
+ if (cur && typeof cur === 'object' && p in cur) cur = cur[p];
83
+ else return '?';
84
+ }
85
+ if (cur == null) return '?';
86
+ return typeof cur === 'string' ? cur : JSON.stringify(cur);
87
+ });
88
+ }
89
+
90
+ async function runHttpProbe(
91
+ test: ConnectorTest,
92
+ settings: Record<string, unknown>,
93
+ def: ConnectorDefinition,
94
+ ): Promise<TestResult> {
95
+ const spec = test.request;
96
+ if (!spec?.url) return { ok: false, error: 'test.request.url is required for http probe' };
97
+
98
+ // Multi-instance overlay: first instance only (matches tool-dispatcher).
99
+ let effectiveSettings = settings as Record<string, any>;
100
+ let instances = effectiveSettings?.instances;
101
+ if (typeof instances === 'string') {
102
+ try { instances = JSON.parse(instances); } catch { instances = null; }
103
+ }
104
+ if (
105
+ Array.isArray(instances) &&
106
+ instances.length > 0 &&
107
+ instances.every((i: any) => i && typeof i === 'object' && typeof i.name === 'string')
108
+ ) {
109
+ effectiveSettings = { ...effectiveSettings, ...instances[0] };
110
+ }
111
+
112
+ const method = (spec.method || 'GET').toUpperCase();
113
+ let url = buildUrl(spec, effectiveSettings);
114
+ const headers = buildHeaders(spec, effectiveSettings);
115
+ const { body, contentType } = buildBody(spec, effectiveSettings);
116
+ if (body != null && contentType && !headers.has('content-type')) {
117
+ headers.set('content-type', contentType);
118
+ }
119
+ url = await applyAuth(url, headers, def.auth, effectiveSettings);
120
+
121
+ const timeoutMs = test.timeout_ms || DEFAULT_TIMEOUT_MS;
122
+ const okStatus = test.ok_status?.length ? test.ok_status : [200];
123
+
124
+ const ctrl = new AbortController();
125
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
126
+ const t0 = Date.now();
127
+ let res: Response;
128
+ try {
129
+ res = await fetch(url, { method, headers, body, signal: ctrl.signal });
130
+ } catch (e) {
131
+ clearTimeout(timer);
132
+ const err = e as Error & { cause?: unknown };
133
+ const cause = err.cause instanceof Error ? `: ${err.cause.message}` : '';
134
+ return {
135
+ ok: false,
136
+ error: `request failed: ${err.message}${cause}`,
137
+ duration_ms: Date.now() - t0,
138
+ };
139
+ }
140
+ clearTimeout(timer);
141
+
142
+ const duration = Date.now() - t0;
143
+ const text = await res.text().catch(() => '');
144
+ const preview = text.length > MAX_BODY_PREVIEW ? text.slice(0, MAX_BODY_PREVIEW) + '…' : text;
145
+
146
+ if (!okStatus.includes(res.status)) {
147
+ let errMsg = `HTTP ${res.status} ${res.statusText}`;
148
+ try {
149
+ const j = JSON.parse(text);
150
+ if (typeof j?.error === 'string') errMsg += `: ${j.error}`;
151
+ else if (typeof j?.message === 'string') errMsg += `: ${j.message}`;
152
+ else if (typeof j?.error_description === 'string') errMsg += `: ${j.error_description}`;
153
+ } catch {}
154
+ return {
155
+ ok: false,
156
+ status: res.status,
157
+ error: errMsg,
158
+ duration_ms: duration,
159
+ body_preview: preview,
160
+ };
161
+ }
162
+
163
+ let parsedBody: unknown = null;
164
+ try { parsedBody = JSON.parse(text); } catch {}
165
+ const message = test.ok_template
166
+ ? renderTemplate(test.ok_template, parsedBody)
167
+ : `OK (HTTP ${res.status})`;
168
+ return { ok: true, status: res.status, message, duration_ms: duration };
169
+ }
170
+
171
+ async function runBrowserProbe(
172
+ def: ConnectorDefinition,
173
+ settings: Record<string, unknown>,
174
+ ): Promise<TestResult> {
175
+ const entries = getConnectorEntries(def);
176
+ const entry = entries[0];
177
+ const hostMatch = def.host_match || entry?.host_match;
178
+ const loginRedirect = def.login_redirect || entry?.login_redirect;
179
+ if (!hostMatch) {
180
+ return { ok: false, error: 'browser probe requires host_match on the manifest' };
181
+ }
182
+ const expandedHost = expandSettingsTokens(hostMatch, settings as any);
183
+ const expandedLoginRedirect = loginRedirect
184
+ ? expandSettingsTokens(loginRedirect, settings as any)
185
+ : undefined;
186
+
187
+ const t0 = Date.now();
188
+ let value: unknown;
189
+ try {
190
+ value = await bridgeRpc('connector.probe', {
191
+ pluginId: def.id,
192
+ host_match: expandedHost,
193
+ login_redirect: expandedLoginRedirect,
194
+ runner: def.runner || entry?.runner || 'main',
195
+ timeout_ms: def.test?.timeout_ms || 30_000,
196
+ });
197
+ } catch (e) {
198
+ const raw = (e as Error).message || String(e);
199
+ let friendly = raw;
200
+ if (raw.includes('unknown method: connector.probe')) {
201
+ friendly =
202
+ 'Your Forge browser extension is out of date — it doesn\'t know how to run browser probes yet. ' +
203
+ 'Rebuild the extension (pnpm ext in forge-browser-extension), then chrome://extensions → Reload, and try Test again.';
204
+ } else if (raw.includes('bridge') && raw.includes('unreachable')) {
205
+ friendly =
206
+ 'Forge browser bridge is unreachable on port 8407. Restart Forge (forge server restart) or check that the browser-bridge standalone is running.';
207
+ } else if (raw.includes('no paired extensions') || raw.includes('no connected')) {
208
+ friendly =
209
+ 'Forge browser extension not connected. Install the extension from forge-browser-extension/dist, pin it, and sign in with your Forge URL + admin password.';
210
+ }
211
+ return { ok: false, error: friendly, duration_ms: Date.now() - t0 };
212
+ }
213
+ const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
214
+ return {
215
+ ok: !!r.ok,
216
+ message: r.ok ? `Session active${r.url ? ` · ${r.url}` : ''}` : undefined,
217
+ error: r.ok ? undefined : (r.error || 'login required'),
218
+ url: r.url,
219
+ duration_ms: Date.now() - t0,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Run a connector's `test:` probe. Returns a structured result. The HTTP
225
+ * route + Login Status panel both call this — they share the same
226
+ * semantics for free.
227
+ */
228
+ export async function runConnectorTest(id: string): Promise<TestResult & { code?: number }> {
229
+ const def = getConnector(id);
230
+ if (!def) return { ok: false, error: 'connector not found', code: 404 };
231
+ if (!def.test) return { ok: false, error: 'connector has no test block', code: 400 };
232
+ const inst = getInstalledConnector(id);
233
+ if (!inst) return { ok: false, error: 'connector not installed', code: 400 };
234
+
235
+ const probe = def.test.probe || 'http';
236
+ return probe === 'browser'
237
+ ? await runBrowserProbe(def, inst.config)
238
+ : await runHttpProbe(def.test, inst.config, def);
239
+ }
@@ -159,7 +159,36 @@ export type ConnectorAuth =
159
159
  | { type: 'basic'; username: string; password: string }
160
160
  | { type: 'bearer'; token: string }
161
161
  | { type: 'header'; name: string; value: string }
162
- | { type: 'query'; name: string; value: string };
162
+ | { type: 'query'; name: string; value: string }
163
+ /**
164
+ * Two-step bearer-token exchange, e.g. Black Duck:
165
+ * POST {exchange_url} Authorization: {exchange_auth_header}
166
+ * → { bearerToken, expiresInMilliseconds }
167
+ * ...then every subsequent request gets `Authorization: Bearer <jwt>`.
168
+ * Forge caches the bearer in-memory keyed by (api_token, exchange_url)
169
+ * and refreshes when within 60s of expiry. Tool calls stay fast — only
170
+ * the first call per ~2h pays the exchange round-trip.
171
+ */
172
+ | {
173
+ type: 'bearer-token-exchange';
174
+ /** Long-lived API token sent to the exchange endpoint. Templated. */
175
+ api_token: string;
176
+ /** Exchange URL. Templated — usually `{base_url}/api/tokens/authenticate`. */
177
+ exchange_url: string;
178
+ /** HTTP method for the exchange. Default POST. */
179
+ exchange_method?: 'POST' | 'GET';
180
+ /**
181
+ * Authorization header VALUE sent to the exchange endpoint.
182
+ * Default `token {api_token}` (Black Duck style). Templated.
183
+ */
184
+ exchange_auth_header?: string;
185
+ /** Extra headers (Accept etc.) for the exchange. Values templated. */
186
+ exchange_headers?: Record<string, string>;
187
+ /** JSON path to the bearer in the exchange response. Default `bearerToken`. */
188
+ bearer_path?: string;
189
+ /** JSON path to expiry ms in the exchange response. Default `expiresInMilliseconds`. */
190
+ expires_path?: string;
191
+ };
163
192
 
164
193
  /**
165
194
  * Server-side HTTP request shape, used by `protocol: http` tools.
@@ -205,6 +234,21 @@ export interface HttpRequestSpec {
205
234
  * rather than baking them into the manifest.
206
235
  */
207
236
  body_form_inject_from?: string;
237
+ /**
238
+ * Names (case-insensitive) of response headers to surface in the
239
+ * truncated preamble. Default empty. Use when the connector needs
240
+ * a value the upstream returns via headers — typically `set-cookie`
241
+ * for session bootstrap, `location` for create-redirect APIs, or
242
+ * `x-ratelimit-remaining` for backoff logic.
243
+ *
244
+ * Headers appear between the status line and the blank line that
245
+ * separates preamble from body, one per line as `name: value`.
246
+ * Pipeline scripts can grep them with sed/awk before the body parse:
247
+ * JS=$(echo "$RESP" | jq -r '.content' | sed -n 's/^set-cookie: //p' | …)
248
+ *
249
+ * Ignored when `noTruncation: true` (raw-body mode for Jobs scheduler).
250
+ */
251
+ capture_response_headers?: string[];
208
252
  }
209
253
 
210
254
  /**
@@ -432,6 +476,19 @@ export interface ConnectorDefinition {
432
476
  */
433
477
  auth?: ConnectorAuth;
434
478
 
479
+ /**
480
+ * Connector-level HTTP runtime knobs. Apply to every `protocol: http`
481
+ * tool in this connector unless overridden per-tool.
482
+ *
483
+ * `verify_tls: false` disables TLS cert verification — necessary for
484
+ * appliances that ship self-signed certs (FortiNAC, ESXi, internal
485
+ * services). Set this ONLY for hosts you trust by IP; turning it off
486
+ * means an attacker on-path could impersonate the server.
487
+ */
488
+ http?: {
489
+ verify_tls?: boolean;
490
+ };
491
+
435
492
  // ─── 1:N suite — list of sibling entries sharing auth ──
436
493
  connectors?: ConnectorEntry[];
437
494
 
@@ -25,29 +25,44 @@ chrome-devtools-mcp ──CDP──▶ Chrome (user's real instance)
25
25
 
26
26
  ### 1. Start Chrome with remote debugging enabled
27
27
 
28
- Quit all Chrome windows first (so the new instance can take the debug
29
- port).
28
+ **Easiest path use the bundled script:**
29
+ ```bash
30
+ ./scripts/chrome-mcp.sh restart # stop + start with debug port 9222
31
+ ./scripts/chrome-mcp.sh status # check whether port is up
32
+ ```
33
+ Subcommands: `start`, `stop`, `restart`, `status`. Use `-p PORT` to override.
34
+
35
+ **Manual path** — quit all Chrome windows first (so the new instance
36
+ can take the debug port), then:
30
37
 
31
38
  **macOS:**
32
39
  ```bash
33
40
  /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
34
41
  --remote-debugging-port=9222 \
35
- --user-data-dir="$HOME/Library/Application Support/Google/Chrome"
42
+ --user-data-dir="$HOME/.chrome-mcp-profile"
36
43
  ```
37
44
 
38
- The `--user-data-dir` argument keeps your normal profile (and all your
39
- logins) drop it only if you want a fresh profile.
45
+ **IMPORTANT — non-default profile required.** Chrome refuses to open
46
+ the remote debug port on the default user-data-dir (security guard, so
47
+ localhost code can't hijack your real Chrome with all its logins). The
48
+ error message reads: `DevTools remote debugging requires a non-default
49
+ data directory`.
50
+
51
+ Use a separate path like `$HOME/.chrome-mcp-profile`. **One-time cost:**
52
+ this profile starts empty, so you log in to NAC / Mantis / GitLab once
53
+ inside it. Cookies persist across restarts — wipe with
54
+ `rm -rf "$HOME/.chrome-mcp-profile"` if you want a fresh start.
40
55
 
41
56
  **Linux:**
42
57
  ```bash
43
- google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/google-chrome"
58
+ google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.chrome-mcp-profile"
44
59
  ```
45
60
 
46
61
  **Windows:**
47
62
  ```powershell
48
63
  "C:\Program Files\Google\Chrome\Application\chrome.exe" `
49
64
  --remote-debugging-port=9222 `
50
- --user-data-dir="$env:LOCALAPPDATA\Google\Chrome\User Data"
65
+ --user-data-dir="$env:USERPROFILE\.chrome-mcp-profile"
51
66
  ```
52
67
 
53
68
  Verify it worked: open `http://localhost:9222/json/version` in the new
package/lib/pipeline.ts CHANGED
@@ -1930,8 +1930,8 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
1930
1930
  if (skillsAppend) {
1931
1931
  taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
1932
1932
  }
1933
- // Pipeline tasks use the same model selection as normal tasks:
1934
- // agent model > global taskModel. No separate pipelineModel override.
1933
+ // Pipeline tasks use the same model selection as normal tasks
1934
+ // (per-task override > agent scene model > agent default).
1935
1935
 
1936
1936
  nodeState.status = 'running';
1937
1937
  nodeState.taskId = task.id;
package/lib/settings.ts CHANGED
@@ -94,9 +94,6 @@ export interface Settings {
94
94
  notifyOnFailure: boolean;
95
95
  tunnelAutoStart: boolean;
96
96
  telegramTunnelPassword: string;
97
- taskModel: string;
98
- pipelineModel: string;
99
- telegramModel: string;
100
97
  skipPermissions: boolean;
101
98
  manageClaudeConfig: boolean;
102
99
  notificationRetentionDays: number;
@@ -206,9 +203,6 @@ const defaults: Settings = {
206
203
  notifyOnFailure: true,
207
204
  tunnelAutoStart: false,
208
205
  telegramTunnelPassword: '',
209
- taskModel: 'default',
210
- pipelineModel: 'default',
211
- telegramModel: 'sonnet',
212
206
  skipPermissions: false,
213
207
  manageClaudeConfig: true,
214
208
  notificationRetentionDays: 30,
@@ -310,6 +304,23 @@ export function loadSettings(): Settings {
310
304
  );
311
305
  }
312
306
  }
307
+ // taskModel / pipelineModel / telegramModel were removed: no UI ever
308
+ // exposed them, and the resolution chain now goes per-task override
309
+ // → agent scene model → agent default. Drop leftover keys; warn when
310
+ // they held a non-default value so the user knows their override
311
+ // stopped applying.
312
+ for (const k of ['taskModel', 'pipelineModel', 'telegramModel'] as const) {
313
+ if (k in parsed) {
314
+ const old = parsed[k];
315
+ delete parsed[k];
316
+ if (old && old !== '' && old !== 'default' && old !== 'sonnet') {
317
+ console.warn(
318
+ `[settings] ${k}="${old}" is no longer read. ` +
319
+ `Set the model on the relevant agent (settings.agents.<id>.models.task) instead.`,
320
+ );
321
+ }
322
+ }
323
+ }
313
324
  // Decrypt top-level secret fields
314
325
  for (const field of SECRET_FIELDS) {
315
326
  if (parsed[field] && isEncrypted(parsed[field])) {
@@ -54,7 +54,7 @@ function db() {
54
54
  return getDb(getDbPath());
55
55
  }
56
56
 
57
- // Per-task model overrides (used by pipeline to set pipelineModel)
57
+ // Per-task model overrides top of the model-resolution chain.
58
58
  export const taskModelOverrides = new Map<string, string>();
59
59
 
60
60
  /**
@@ -514,12 +514,12 @@ function executeTask(task: Task): Promise<void> {
514
514
  const agentId = (task as any).agent || settings.defaultAgent || 'claude';
515
515
  const adapter = getAgent(agentId);
516
516
 
517
- // Model priority: per-task override > agent scene model > global taskModel
518
- // "default" means "no override" — fall through to next level
517
+ // Model priority: per-task override > agent scene model > 'default'
518
+ // (agent picks its own default). "default" means "no override".
519
519
  const agentCfg = settings.agents?.[agentId];
520
520
  const agentModel = agentCfg?.models?.task;
521
521
  const effectiveAgentModel = agentModel && agentModel !== 'default' ? agentModel : null;
522
- const model = taskModelOverrides.get(task.id) || effectiveAgentModel || settings.taskModel;
522
+ const model = taskModelOverrides.get(task.id) || effectiveAgentModel || 'default';
523
523
  const supportsModel = adapter.config.capabilities?.supportsModel;
524
524
  const spawnOpts = adapter.buildTaskSpawn({
525
525
  projectPath: task.projectPath,
@@ -736,7 +736,7 @@ async function handlePeek(chatId: number, projectArg?: string, sessionArg?: stri
736
736
  contextLen += line.length;
737
737
  }
738
738
 
739
- const telegramModel = loadSettings().telegramModel || 'sonnet';
739
+ const telegramModel = 'sonnet';
740
740
  const summary = contextEntries.length > 3
741
741
  ? await aiSummarize(contextEntries.join('\n'), 'Summarize this Claude Code session in 2-3 sentences. What was the user working on? What is the current status? Answer in the same language as the content.')
742
742
  : '';
@@ -1353,7 +1353,7 @@ async function aiSummarize(content: string, instruction: string): Promise<string
1353
1353
  try {
1354
1354
  const settings = loadSettings();
1355
1355
  const claudePath = settings.claudePath || process.env.CLAUDE_PATH || 'claude';
1356
- const model = settings.telegramModel || 'sonnet';
1356
+ const model = 'sonnet';
1357
1357
  const { execSync } = require('child_process');
1358
1358
  const { realpathSync } = require('fs');
1359
1359
 
@@ -1509,7 +1509,7 @@ async function handleDocs(chatId: number, input: string) {
1509
1509
  } catch {}
1510
1510
 
1511
1511
  const recent = entries.slice(-8).join('\n\n');
1512
- const tModel = loadSettings().telegramModel || 'sonnet';
1512
+ const tModel = 'sonnet';
1513
1513
  const summary = entries.length > 3
1514
1514
  ? await aiSummarize(entries.slice(-15).join('\n'), 'Summarize this Claude Code session in 2-3 sentences. What was the user working on? What is the current status? Answer in the same language as the content.')
1515
1515
  : '';
@@ -89,7 +89,11 @@ export function startWatchRunner(hooks: WatchRunnerHooks = {}): void {
89
89
  }
90
90
  let res;
91
91
  try {
92
- res = await dispatchTool({ id: `watch-${w.id}-${w.polls}`, name: `${w.connector_id}.${w.poll_tool}`, input: w.poll_args }, { noTruncation: false } as any);
92
+ // noTruncation=true so http-protocol polls skip the "HTTP 200 OK · GET …"
93
+ // preamble — the body comes back as raw JSON so done_path/done_match can
94
+ // see it. (Without this, every http-protocol watch would silently never
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);
93
97
  } catch (e) {
94
98
  res = { content: String(e), is_error: true };
95
99
  }
@@ -129,7 +133,21 @@ export function startWatchRunner(hooks: WatchRunnerHooks = {}): void {
129
133
  if (polls >= w.max_polls || now - w.created_at > w.timeout_sec * 1000) {
130
134
  return finish(w, 'timed_out', obj, `${w.label}: not done within ${w.max_polls} polls / ${w.timeout_sec}s — please verify manually.`);
131
135
  }
132
- updateWatch(w.id, { polls, err_count: 0, next_poll_at: now + w.interval_sec * 1000 }, now);
136
+ // Persist the latest poll result + a tiny preview text on EVERY poll
137
+ // (not just terminal) so the Monitor / DB shows what the watch is
138
+ // actually seeing — crucial for diagnosing "polling forever, no done":
139
+ // usually means the done condition refers to a field the poll's
140
+ // result doesn't actually have.
141
+ const previewKeys = obj && typeof obj === 'object' && !Array.isArray(obj)
142
+ ? Object.keys(obj as Record<string, unknown>).slice(0, 8).join(', ')
143
+ : typeof obj;
144
+ updateWatch(w.id, {
145
+ polls,
146
+ err_count: 0,
147
+ next_poll_at: now + w.interval_sec * 1000,
148
+ last_result: obj,
149
+ last_text: `poll ${polls}/${w.max_polls} · keys: ${previewKeys}`,
150
+ }, now);
133
151
  emitProgress(w, obj, polls);
134
152
  };
135
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.23",
3
+ "version": "0.10.26",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -55,6 +55,7 @@
55
55
  "react-dom": "^19.2.4",
56
56
  "react-markdown": "^10.1.0",
57
57
  "remark-gfm": "^4.0.1",
58
+ "undici": "^8.3.0",
58
59
  "ws": "^8.19.0",
59
60
  "yaml": "^2.8.2",
60
61
  "zod": "^4.3.6"