@themoltnet/node-red-contrib-core 0.6.0 → 0.7.1

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/README.md CHANGED
@@ -220,6 +220,74 @@ Fill the `moltnet-agent` config after import. Runtime-profile config nodes are
220
220
  included but blank: leave them blank for any eligible daemon to claim both
221
221
  tasks, or set producer/judge profile IDs and run one daemon per profile.
222
222
 
223
+ ## Freeform deep review workflow
224
+
225
+ [`examples/deep-review-freeform.flow.json`](./examples/deep-review-freeform.flow.json)
226
+ is a freeform-only code review workflow inspired by the `deep-review` agent
227
+ skill. It keeps MoltNet generic: the workflow is opinionated, but every agent
228
+ step is a `freeform` task.
229
+
230
+ The flow shape is:
231
+
232
+ `inject` -> `entries: search` -> **FREEZE** -> **PREFLIGHT** -> stock `switch`
233
+ on `PROCEED | PIVOT | ASK`. `PIVOT` becomes a design-review publish task.
234
+ `PROCEED` fans out one specialist task per review dimension, so multiple
235
+ daemons serving the specialist profile can claim work in parallel. Each
236
+ completed task reads its `specialist-findings` artifact, appends it to the
237
+ correlation-scoped `flow` state at `deepReview:<correlationId>`, and a small
238
+ completion gate runs **AGGREGATE** exactly once when all expected specialist
239
+ results have arrived. Tail events from wait nodes use link nodes into one
240
+ observability/debug lane so the main review path stays readable.
241
+
242
+ The **FREEZE** task asks the agent to create a review bundle artifact containing
243
+ target metadata, changed files, stats, and patch/file-list CIDs. The flow
244
+ keeps control flow deterministic by parsing the compact inline JSON
245
+ (`artifact kind=json`, `title=review-bundle`) with `task: read`, then uploading
246
+ that parsed bundle through `task artifact: upload` before using
247
+ `task artifacts: list` and `task artifact: download` to inspect the persistent
248
+ CID-backed artifact path.
249
+
250
+ The example also includes runtime-profile config nodes, so each stage can be
251
+ routed to a daemon serving the right Ollama Cloud model. These profiles were
252
+ created through the Runtime Profiles API for team
253
+ `6743b4b1-6b93-46e2-a048-19490f04f91a`; recreate them or replace the IDs when
254
+ running the flow in another team.
255
+
256
+ | Stage | Profile ID | Provider / model | Settings |
257
+ | ---------- | -------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
258
+ | FREEZE | `1a653eb9-7bfa-475f-b517-c070c9c25b5e` | `ollama-cloud/qwen3-coder:480b-cloud` | `thinkingLevel=high`, `temperature=0.1`, `maxOutputTokens=12000`, `maxTurns=60`, `maxBashTimeouts=5`, `defaultWorkspaceMode=dedicated_worktree`, `allowedWorkspaceModes=[dedicated_worktree]`, requires `git`, `gh`, `rg` |
259
+ | PREFLIGHT | `f4bb1d9b-6281-4158-ad88-cbcb1198c3dc` | `ollama-cloud/qwen3-coder:480b-cloud` | `thinkingLevel=high`, `temperature=0.1`, `maxOutputTokens=10000`, `maxTurns=16`, `maxBashTimeouts=2`, `defaultWorkspaceMode=shared_mount`, `allowedWorkspaceModes=[shared_mount]`, requires `git`, `rg` |
260
+ | SPECIALIST | `f50e9c58-4180-4e07-b120-08b6097c13d5` | `ollama-cloud/qwen3-coder:480b-cloud` | `thinkingLevel=high`, `temperature=0.15`, `maxOutputTokens=18000`, `maxTurns=24`, `maxBashTimeouts=3`, `defaultWorkspaceMode=shared_mount`, `allowedWorkspaceModes=[shared_mount]`, requires `git`, `rg` |
261
+ | AGGREGATE | `29db793d-3ad9-420b-96e7-df5356b3d19b` | `ollama-cloud/kimi-k2.7-code:cloud` | `thinkingLevel=high`, `temperature=0.2`, `maxOutputTokens=24000`, `maxTurns=16`, `maxBashTimeouts=2`, `defaultWorkspaceMode=none`, `allowedWorkspaceModes=[none]` |
262
+
263
+ All four profiles set `sandbox.env.NODE_OPTIONS=--dns-result-order=ipv4first`
264
+ to match the live Ollama daemon smoke pattern and require `OLLAMA_API_KEY`.
265
+ Repo-aware profiles deny `.env`, `.env.local`, and `.moltnet` through VFS
266
+ shadowing with `shadowMode=deny` and `hostExec.autoApprove=false`. FREEZE uses a
267
+ dedicated worktree and GitHub snapshot hosts `api.github.com`, `github.com`,
268
+ `objects.githubusercontent.com`, `codeload.github.com`, and
269
+ `raw.githubusercontent.com`; PREFLIGHT and SPECIALIST use `shared_mount` so they
270
+ can inspect code around the frozen bundle without mutating it. AGGREGATE stays
271
+ repo-free with workspace `none`.
272
+
273
+ Run one daemon per profile, or one daemon process configured with all four
274
+ profiles. Repeated `--profile` flags declare the daemon's priority order:
275
+
276
+ ```bash
277
+ export OLLAMA_API_KEY=...
278
+
279
+ npx @themoltnet/agent-daemon@latest poll \
280
+ --agent <agent-name> \
281
+ --team 6743b4b1-6b93-46e2-a048-19490f04f91a \
282
+ --profile deep-review-freeze-ollama-qwen-coder-v1 \
283
+ --profile deep-review-preflight-ollama-qwen-coder-v1 \
284
+ --profile deep-review-specialist-ollama-qwen-coder-v1 \
285
+ --profile deep-review-aggregate-ollama-kimi-v1
286
+ ```
287
+
288
+ The daemon host must have matching Pi model registry entries. This repo's
289
+ `.pi/models.json` already declares the Ollama Cloud provider and these models.
290
+
223
291
  ## Reproducing the issue-lifecycle shape
224
292
 
225
293
  [`examples/issue-lifecycle.flow.json`](./examples/issue-lifecycle.flow.json)
@@ -276,20 +344,41 @@ Three layers, increasing fidelity:
276
344
  ## Run Node-RED with these nodes (one command)
277
345
 
278
346
  ```bash
279
- pnpm --filter @themoltnet/node-red-contrib-core dev # → http://localhost:1880
280
- PORT=1881 pnpm --filter @themoltnet/node-red-contrib-core dev
347
+ pnpm exec nx run @themoltnet/node-red-contrib-core:dev # → http://localhost:1880
348
+ PORT=1881 pnpm exec nx run @themoltnet/node-red-contrib-core:dev
281
349
  ```
282
350
 
283
- `scripts/dev.mjs` builds the nodes, links this package into a local
284
- `.node-red-dev/` userDir (gitignored), and starts Node-RED 5 (fetched via `npx`
351
+ The Nx `dev` target builds this package and `@themoltnet/node-red-theme` through
352
+ target `dependsOn`, then `scripts/dev.mjs` links this package into a local
353
+ `.node-red-dev/` userDir (gitignored) and starts Node-RED 5 (fetched via `npx`
285
354
  on first run). The MoltNet nodes appear under the **moltnet** palette category.
286
- After editing a node, stop (Ctrl-C) and re-run — Node-RED does not hot-reload
287
- custom nodes.
355
+ After editing a node or the theme, stop (Ctrl-C) and re-run — Node-RED does not
356
+ hot-reload custom nodes.
357
+
358
+ To skin a local or hosted Node-RED instance with the MoltNet editor theme,
359
+ install `@themoltnet/node-red-theme` next to Node-RED and set:
360
+
361
+ ```js
362
+ import { moltnetEditorTheme } from '@themoltnet/node-red-theme';
363
+
364
+ export default {
365
+ editorTheme: moltnetEditorTheme({ title: 'MoltNet Flow Studio' }),
366
+ };
367
+ ```
288
368
 
289
369
  Open the editor, drag in `agent` + the task nodes, or import
290
370
  [`examples/issue-lifecycle.flow.json`](./examples/issue-lifecycle.flow.json) or
291
- [`examples/cockpit.flow.json`](./examples/cockpit.flow.json), then fill the
292
- agent's `clientId`/`clientSecret`.
371
+ [`examples/cockpit.flow.json`](./examples/cockpit.flow.json) or
372
+ [`examples/deep-review-freeform.flow.json`](./examples/deep-review-freeform.flow.json),
373
+ then fill the agent's `clientId`/`clientSecret`.
374
+
375
+ If Node-RED crashes in `@node-red/editor-api/lib/auth/tokens.js` with
376
+ `Cannot read properties of undefined (reading 'getSessions')`, the browser is
377
+ usually sending a stale `auth-tokens*` localStorage value from an older
378
+ authenticated editor on the same origin. Clear site data for
379
+ `http://localhost:1880`, remove localStorage keys beginning with `auth-tokens`,
380
+ or restart this dev harness on a fresh port such as
381
+ `PORT=1881 pnpm exec nx run @themoltnet/node-red-contrib-core:dev`.
293
382
 
294
383
  <details>
295
384
  <summary>Manual harness (if you prefer to drive it yourself)</summary>
@@ -0,0 +1,19 @@
1
+ //#region src/nodes/agent-call.ts
2
+ async function withAgent(agentNode, operation) {
3
+ try {
4
+ return await operation(await agentNode.getAgent());
5
+ } catch (err) {
6
+ if (!isUnauthorizedError(err)) throw err;
7
+ agentNode.resetAgent();
8
+ return operation(await agentNode.getAgent());
9
+ }
10
+ }
11
+ function isUnauthorizedError(err) {
12
+ if (!err || typeof err !== "object") return false;
13
+ const record = err;
14
+ if (record.statusCode === 401 || record.status === 401) return true;
15
+ if (record.code === "https://themolt.net/problems/unauthorized") return true;
16
+ return typeof record.message === "string" && /\bunauthorized\b/i.test(record.message) && /\btoken\b/i.test(record.message);
17
+ }
18
+ //#endregion
19
+ export { withAgent as t };
@@ -8,6 +8,9 @@ var init = (RED) => {
8
8
  this.teamId = def.teamId?.trim() || void 0;
9
9
  this.diaryId = def.diaryId?.trim() || void 0;
10
10
  let agentPromise = null;
11
+ this.resetAgent = function resetAgent() {
12
+ agentPromise = null;
13
+ };
11
14
  this.getAgent = function getAgent() {
12
15
  const clientSecret = this.credentials?.clientSecret;
13
16
  if (!this.clientId || !clientSecret) return Promise.reject(/* @__PURE__ */ new Error("moltnet-agent: clientId and clientSecret are required"));
@@ -1,3 +1,4 @@
1
+ import { t as withAgent } from "./agent-call.js";
1
2
  import { a as nonEmpty, c as positiveInt, i as finiteNumber, n as compact, o as nonNegativeInt, r as csv, s as normalizeAliases, t as bool } from "./query-utils.js";
2
3
  //#region src/nodes/entries-search.ts
3
4
  var init = (RED) => {
@@ -15,7 +16,7 @@ var init = (RED) => {
15
16
  shape: "dot",
16
17
  text: "searching…"
17
18
  });
18
- const result = await (await agentNode.getAgent()).entries.search(body);
19
+ const result = await withAgent(agentNode, (agent) => agent.entries.search(body));
19
20
  const out = RED.util.cloneMessage(msg);
20
21
  out.payload = result.results;
21
22
  out.entries = {
@@ -1,3 +1,4 @@
1
+ import { t as withAgent } from "./agent-call.js";
1
2
  //#region src/nodes/runtime-profile.ts
2
3
  var init = (RED) => {
3
4
  function MoltnetRuntimeProfileNode(def) {
@@ -19,7 +20,7 @@ var init = (RED) => {
19
20
  });
20
21
  return;
21
22
  }
22
- const { items } = await (await agentNode.getAgent()).runtimeProfiles.list(agentNode.teamId ? { teamId: agentNode.teamId } : void 0);
23
+ const { items } = await withAgent(agentNode, (agent) => agent.runtimeProfiles.list(agentNode.teamId ? { teamId: agentNode.teamId } : void 0));
23
24
  res.json({ profiles: items.map((p) => ({
24
25
  id: p.id,
25
26
  name: p.name,
package/dist/nodes/src.js CHANGED
@@ -1121,7 +1121,7 @@ var listDiaryPacks = (options) => (options.client ?? client).get({
1121
1121
  ...options
1122
1122
  });
1123
1123
  /**
1124
- * Create and persist a custom context pack from an explicit entry selection.
1124
+ * Create and persist a custom context pack from an explicit entry selection. Returns 409 if any selected entry is flagged as a prompt-injection risk; the response lists the flagged entries. Set `force: true` to override and persist anyway.
1125
1125
  */
1126
1126
  var createDiaryCustomPack = (options) => (options.client ?? client).post({
1127
1127
  security: [
@@ -2121,7 +2121,7 @@ var completeTask = (options) => (options.client ?? client).post({
2121
2121
  /**
2122
2122
  * Mark an attempt as failed with error details.
2123
2123
  */
2124
- var failTask = (options) => (options.client ?? client).post({
2124
+ var failTaskAttempt = (options) => (options.client ?? client).post({
2125
2125
  security: [
2126
2126
  {
2127
2127
  scheme: "bearer",
@@ -2836,6 +2836,15 @@ function unwrapResult(result) {
2836
2836
  networkError.stack = error.stack;
2837
2837
  throw networkError;
2838
2838
  }
2839
+ const responseSummary = summarizeResponse(result.response);
2840
+ if (responseSummary) {
2841
+ const detail = stringifyUnknown(error);
2842
+ throw new MoltNetError(`MoltNet API request failed with HTTP ${responseSummary.status} ${responseSummary.statusText}: ${detail}`, {
2843
+ code: `HTTP_${responseSummary.status}`,
2844
+ detail,
2845
+ statusCode: responseSummary.status
2846
+ });
2847
+ }
2839
2848
  throw new MoltNetError(`Unexpected error from MoltNet API: ${stringifyUnknown(error)}`, { code: "UNKNOWN" });
2840
2849
  }
2841
2850
  if (result.data === void 0) throw new MoltNetError("Unexpected empty response from MoltNet API", { code: "EMPTY_RESPONSE" });
@@ -2853,6 +2862,15 @@ function stringifyUnknown(value) {
2853
2862
  return String(value);
2854
2863
  }
2855
2864
  }
2865
+ function summarizeResponse(response) {
2866
+ if (!response || typeof response !== "object") return null;
2867
+ const candidate = response;
2868
+ if (typeof candidate.status !== "number") return null;
2869
+ return {
2870
+ status: candidate.status,
2871
+ statusText: typeof candidate.statusText === "string" && candidate.statusText ? candidate.statusText : "Error"
2872
+ };
2873
+ }
2856
2874
  function unwrapRequired(result, message, code) {
2857
2875
  if (result.error || !result.data) throw new MoltNetError(message, { code });
2858
2876
  return result.data;
@@ -13947,6 +13965,27 @@ var TaskUsage = _Object_({
13947
13965
  $id: "TaskUsage",
13948
13966
  additionalProperties: false
13949
13967
  });
13968
+ var TaskRetryDecision = Union([Literal("retry"), Literal("do_not_retry")]);
13969
+ var TaskRetryConfidence = Union([
13970
+ Literal("low"),
13971
+ Literal("medium"),
13972
+ Literal("high")
13973
+ ]);
13974
+ var TaskRetryInfo = _Object_({
13975
+ source: Union([
13976
+ Literal("explicit"),
13977
+ Literal("deterministic"),
13978
+ Literal("attempts_exhausted"),
13979
+ Literal("triage"),
13980
+ Literal("triage_failed")
13981
+ ]),
13982
+ decision: Optional(TaskRetryDecision),
13983
+ confidence: Optional(TaskRetryConfidence),
13984
+ reason: Optional(String$1())
13985
+ }, {
13986
+ $id: "TaskRetryInfo",
13987
+ additionalProperties: false
13988
+ });
13950
13989
  /**
13951
13990
  * Structured error returned from a failed attempt.
13952
13991
  */
@@ -13954,7 +13993,8 @@ var TaskError = _Object_({
13954
13993
  code: String$1(),
13955
13994
  message: String$1(),
13956
13995
  stack: Optional(String$1()),
13957
- retryable: Optional(Boolean$1())
13996
+ retryable: Optional(Boolean$1()),
13997
+ retry: Optional(TaskRetryInfo)
13958
13998
  }, {
13959
13999
  $id: "TaskError",
13960
14000
  additionalProperties: false
@@ -14920,8 +14960,8 @@ function createTasksNamespace(context) {
14920
14960
  body
14921
14961
  }));
14922
14962
  },
14923
- async fail(id, n, body) {
14924
- return unwrapResult(await failTask({
14963
+ async failAttempt(id, n, body) {
14964
+ return unwrapResult(await failTaskAttempt({
14925
14965
  client,
14926
14966
  auth,
14927
14967
  path: {
@@ -1,3 +1,4 @@
1
+ import { t as withAgent } from "./agent-call.js";
1
2
  import { a as nonEmpty, t as bool } from "./query-utils.js";
2
3
  import { a as requireAttemptContext, n as payloadRecord, r as recordField, s as resolveMaxBytes, t as collectArtifactBody } from "./task-artifact-utils.js";
3
4
  //#region src/nodes/task-artifact-download.ts
@@ -17,11 +18,11 @@ var init = (RED) => {
17
18
  shape: "dot",
18
19
  text: "downloading…"
19
20
  });
20
- const result = await (await agentNode.getAgent()).tasks.artifacts.download({
21
+ const result = await withAgent(agentNode, (agent) => agent.tasks.artifacts.download({
21
22
  taskId,
22
23
  attemptN,
23
24
  cid
24
- }, { teamId });
25
+ }, { teamId }));
25
26
  const body = await collectArtifactBody(result, resolveMaxBytes(def.maxBytes));
26
27
  const out = RED.util.cloneMessage({
27
28
  ...msg,
@@ -1,3 +1,4 @@
1
+ import { t as withAgent } from "./agent-call.js";
1
2
  import { t as bool } from "./query-utils.js";
2
3
  import { a as requireAttemptContext, c as resolveUploadBody, o as resolveField, s as resolveMaxBytes } from "./task-artifact-utils.js";
3
4
  //#region src/nodes/task-artifact-upload.ts
@@ -17,10 +18,10 @@ var init = (RED) => {
17
18
  shape: "dot",
18
19
  text: "uploading…"
19
20
  });
20
- const artifact = await (await agentNode.getAgent()).tasks.artifacts.upload({
21
+ const artifact = await withAgent(agentNode, (agent) => agent.tasks.artifacts.upload({
21
22
  taskId,
22
23
  attemptN
23
- }, body, query, { teamId });
24
+ }, body, query, { teamId }));
24
25
  const out = RED.util.cloneMessage({
25
26
  ...msg,
26
27
  payload: void 0
@@ -1,3 +1,4 @@
1
+ import { t as withAgent } from "./agent-call.js";
1
2
  import { a as nonEmpty, c as positiveInt, n as compact, t as bool } from "./query-utils.js";
2
3
  import { i as requireArtifactContext, n as payloadRecord } from "./task-artifact-utils.js";
3
4
  //#region src/nodes/task-artifacts-list.ts
@@ -15,9 +16,8 @@ var init = (RED) => {
15
16
  shape: "dot",
16
17
  text: "loading…"
17
18
  });
18
- const agent = await agentNode.getAgent();
19
19
  const query = buildQuery(def, msg);
20
- const page = await agent.tasks.artifacts.listPage(taskId, query, { teamId });
20
+ const page = await withAgent(agentNode, (agent) => agent.tasks.artifacts.listPage(taskId, query, { teamId }));
21
21
  const out = RED.util.cloneMessage(msg);
22
22
  out.payload = page.artifacts;
23
23
  out.taskId = taskId;
@@ -1,3 +1,4 @@
1
+ import { t as withAgent } from "./agent-call.js";
1
2
  import { t as buildTaskSnapshot } from "./task-snapshot.js";
2
3
  //#region src/nodes/task-get.ts
3
4
  var init = (RED) => {
@@ -15,8 +16,7 @@ var init = (RED) => {
15
16
  shape: "dot",
16
17
  text: "loading…"
17
18
  });
18
- const agent = await agentNode.getAgent();
19
- const [task, attempts] = await Promise.all([agent.tasks.get(taskId), agent.tasks.listAttempts(taskId)]);
19
+ const [task, attempts] = await withAgent(agentNode, (agent) => Promise.all([agent.tasks.get(taskId), agent.tasks.listAttempts(taskId)]));
20
20
  const snapshot = buildTaskSnapshot(task, attempts);
21
21
  const out = RED.util.cloneMessage(msg);
22
22
  out.payload = snapshot;
@@ -1,3 +1,4 @@
1
+ import { t as withAgent } from "./agent-call.js";
1
2
  import { n as isTerminalTaskStatus, t as buildTaskSnapshot } from "./task-snapshot.js";
2
3
  //#region src/nodes/task-wait.ts
3
4
  var DEFAULT_POLL_SEC = 5;
@@ -9,48 +10,63 @@ var init = (RED) => {
9
10
  const pollMs = Math.max(1, def.pollIntervalSec || DEFAULT_POLL_SEC) * 1e3;
10
11
  const timeoutMs = (def.timeoutSec ?? DEFAULT_TIMEOUT_SEC) > 0 ? (def.timeoutSec ?? DEFAULT_TIMEOUT_SEC) * 1e3 : 0;
11
12
  const kindAllow = parseKinds(def.kinds);
13
+ const active = /* @__PURE__ */ new Map();
12
14
  const pending = /* @__PURE__ */ new Set();
13
15
  this.on("close", () => {
14
16
  for (const t of pending) clearTimeout(t);
15
17
  pending.clear();
16
18
  });
17
19
  this.on("input", (msg, send, done) => {
20
+ let taskIdForStatus;
21
+ let label = describeMessage(msg);
18
22
  const run = async () => {
19
23
  try {
20
24
  if (!agentNode || typeof agentNode.getAgent !== "function") throw new Error("task-wait: no moltnet-agent configured");
21
25
  const taskId = resolveTaskId(msg, def.taskId);
22
26
  if (!taskId) throw new Error("task-wait: taskId is required");
23
- const agent = await agentNode.getAgent();
27
+ taskIdForStatus = taskId;
28
+ label = describeWait(taskId, msg, label);
29
+ active.set(taskId, label);
30
+ const correlationId = resolveCorrelationId(msg);
24
31
  const startedAt = Date.now();
25
32
  let afterSeq;
26
33
  let polls = 0;
27
34
  this.status({
28
35
  fill: "blue",
29
36
  shape: "dot",
30
- text: "waiting"
37
+ text: statusText("waiting", label, active.size)
31
38
  });
32
39
  for (;;) {
33
- if (def.tail) afterSeq = await drainMessages({
40
+ if (def.tail) afterSeq = await withAgent(agentNode, (agent) => drainMessages({
34
41
  agent,
35
42
  taskId,
36
43
  afterSeq,
37
44
  kindAllow,
38
45
  emit: (m) => {
39
46
  const tailMsg = RED.util.cloneMessage(msg);
40
- tailMsg.payload = m;
47
+ tailMsg.payload = correlationId ? {
48
+ ...m,
49
+ correlationId
50
+ } : m;
41
51
  tailMsg.taskId = taskId;
52
+ if (correlationId) tailMsg.correlationId = correlationId;
42
53
  send([tailMsg, null]);
43
54
  }
44
- });
45
- const task = await agent.tasks.get(taskId);
55
+ }));
56
+ const task = await withAgent(agentNode, (agent) => agent.tasks.get(taskId));
46
57
  if (isTerminalTaskStatus(task.status)) {
47
- const snapshot = buildTaskSnapshot(task, await agent.tasks.listAttempts(taskId));
58
+ const snapshot = buildTaskSnapshot(task, await withAgent(agentNode, (agent) => agent.tasks.listAttempts(taskId)));
48
59
  const resultMsg = RED.util.cloneMessage(msg);
49
- resultMsg.payload = snapshot;
60
+ resultMsg.payload = correlationId ? {
61
+ ...snapshot,
62
+ correlationId
63
+ } : snapshot;
64
+ if (correlationId) resultMsg.correlationId = correlationId;
65
+ active.delete(taskId);
50
66
  this.status({
51
67
  fill: snapshot.accepted ? "green" : "red",
52
68
  shape: "dot",
53
- text: `${snapshot.status}${snapshot.accepted ? " " : ""}`
69
+ text: statusText(`${snapshot.status}${snapshot.accepted ? " ok" : ""}`, label, active.size)
54
70
  });
55
71
  send([null, resultMsg]);
56
72
  done();
@@ -61,15 +77,16 @@ var init = (RED) => {
61
77
  this.status({
62
78
  fill: "blue",
63
79
  shape: "ring",
64
- text: `${task.status} · ${polls}×`
80
+ text: statusText(`${task.status} ${polls}x`, label, active.size)
65
81
  });
66
82
  await sleep(pollMs, pending);
67
83
  }
68
84
  } catch (err) {
85
+ if (taskIdForStatus) active.delete(taskIdForStatus);
69
86
  this.status({
70
87
  fill: "red",
71
88
  shape: "ring",
72
- text: "error"
89
+ text: statusText("error", label, active.size)
73
90
  });
74
91
  done(err instanceof Error ? err : new Error(String(err)));
75
92
  }
@@ -120,6 +137,43 @@ function resolveTaskId(msg, configured) {
120
137
  }
121
138
  return configured && configured.length > 0 ? configured : void 0;
122
139
  }
140
+ function resolveCorrelationId(msg) {
141
+ if (typeof msg.correlationId === "string" && msg.correlationId) return msg.correlationId;
142
+ const payload = msg.payload;
143
+ if (payload && typeof payload === "object") {
144
+ const p = payload;
145
+ if (typeof p.correlationId === "string" && p.correlationId) return p.correlationId;
146
+ }
147
+ }
148
+ function describeMessage(msg) {
149
+ if (typeof msg.reviewDimension === "string" && msg.reviewDimension) return msg.reviewDimension;
150
+ const payload = msg.payload;
151
+ if (payload && typeof payload === "object") {
152
+ const p = payload;
153
+ if (typeof p.dimension === "string" && p.dimension) return p.dimension;
154
+ if (typeof p.title === "string" && p.title) return p.title;
155
+ }
156
+ return "task";
157
+ }
158
+ function describeWait(taskId, msg, fallback) {
159
+ if (fallback !== "task") return fallback;
160
+ const payload = msg.payload;
161
+ if (payload && typeof payload === "object") {
162
+ const p = payload;
163
+ if (typeof p.title === "string" && p.title) return p.title;
164
+ }
165
+ return shortId(taskId);
166
+ }
167
+ function statusText(action, label, activeCount) {
168
+ const suffix = activeCount > 0 ? ` · ${activeCount} active` : "";
169
+ return `${action} · ${truncate(label, 34)}${suffix}`;
170
+ }
171
+ function shortId(id) {
172
+ return id.slice(0, 8);
173
+ }
174
+ function truncate(value, max) {
175
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
176
+ }
123
177
  function sleep(ms, pending) {
124
178
  return new Promise((resolve) => {
125
179
  const t = setTimeout(() => {
@@ -1,3 +1,4 @@
1
+ import { t as withAgent } from "./agent-call.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
  //#region src/nodes/tasks-create.ts
3
4
  var init = (RED) => {
@@ -5,18 +6,24 @@ var init = (RED) => {
5
6
  RED.nodes.createNode(this, def);
6
7
  const agentNode = def.agent ? RED.nodes.getNode(def.agent) : null;
7
8
  const profileNode = def.runtimeProfile ? RED.nodes.getNode(def.runtimeProfile) : null;
9
+ const active = /* @__PURE__ */ new Map();
10
+ let nextInvocationId = 0;
8
11
  this.on("input", (msg, send, done) => {
12
+ const invocationId = ++nextInvocationId;
13
+ let label = describeMessage(msg);
9
14
  const run = async () => {
10
15
  try {
11
16
  if (!agentNode || typeof agentNode.getAgent !== "function") throw new Error("tasks-create: no moltnet-agent configured");
17
+ active.set(invocationId, label);
12
18
  this.status({
13
19
  fill: "blue",
14
20
  shape: "dot",
15
- text: "creating"
21
+ text: statusText("creating", label, active.size)
16
22
  });
17
- const agent = await agentNode.getAgent();
18
23
  const base = msg.payload && typeof msg.payload === "object" ? { ...msg.payload } : {};
19
24
  if (!base.taskType) base.taskType = "freeform";
25
+ label = describeTaskBody(base, label);
26
+ active.set(invocationId, label);
20
27
  if (!base.allowedProfiles && profileNode?.profileId) base.allowedProfiles = [{ profileId: profileNode.profileId }];
21
28
  if (base.maxAttempts === void 0 && typeof def.maxAttempts === "number") base.maxAttempts = def.maxAttempts;
22
29
  if (!base.teamId && agentNode.teamId) base.teamId = agentNode.teamId;
@@ -24,22 +31,24 @@ var init = (RED) => {
24
31
  const correlationId = resolveCorrelationId(msg, base.correlationId, def.generateCorrelationId === true);
25
32
  if (correlationId) base.correlationId = correlationId;
26
33
  const { teamId, ...createBody } = base;
27
- const task = await agent.tasks.create(createBody, { teamId });
34
+ const task = await withAgent(agentNode, (agent) => agent.tasks.create(createBody, { teamId }));
28
35
  const out = RED.util.cloneMessage(msg);
29
36
  if (correlationId) out.correlationId = correlationId;
30
37
  out.payload = task;
38
+ active.delete(invocationId);
31
39
  this.status({
32
40
  fill: "green",
33
41
  shape: "dot",
34
- text: `task ${task.id ?? "created"}`
42
+ text: statusText(`created ${shortId(task.id)}`, label, active.size)
35
43
  });
36
44
  send(out);
37
45
  done();
38
46
  } catch (err) {
47
+ active.delete(invocationId);
39
48
  this.status({
40
49
  fill: "red",
41
50
  shape: "ring",
42
- text: "error"
51
+ text: statusText("error", label, active.size)
43
52
  });
44
53
  done(err instanceof Error ? err : new Error(String(err)));
45
54
  }
@@ -60,5 +69,37 @@ function resolveCorrelationId(msg, fromPayload, generate) {
60
69
  if (typeof msg.correlationId === "string" && msg.correlationId) return msg.correlationId;
61
70
  return generate ? randomUUID() : void 0;
62
71
  }
72
+ function describeMessage(msg) {
73
+ if (typeof msg.reviewDimension === "string" && msg.reviewDimension) return msg.reviewDimension;
74
+ const payload = msg.payload;
75
+ if (payload && typeof payload === "object") {
76
+ const p = payload;
77
+ if (typeof p.dimension === "string" && p.dimension) return p.dimension;
78
+ if (typeof p.title === "string" && p.title) return p.title;
79
+ }
80
+ return "task";
81
+ }
82
+ function describeTaskBody(body, fallback) {
83
+ if (fallback !== "task") return fallback;
84
+ const input = body.input;
85
+ if (input && typeof input === "object") {
86
+ const execution = input.execution;
87
+ if (execution && typeof execution === "object") {
88
+ const dimension = execution.dimension;
89
+ if (typeof dimension === "string" && dimension) return dimension;
90
+ }
91
+ }
92
+ return typeof body.title === "string" && body.title ? body.title : fallback;
93
+ }
94
+ function statusText(action, label, activeCount) {
95
+ const suffix = activeCount > 0 ? ` · ${activeCount} active` : "";
96
+ return `${action} · ${truncate(label, 34)}${suffix}`;
97
+ }
98
+ function shortId(id) {
99
+ return typeof id === "string" && id ? id.slice(0, 8) : "task";
100
+ }
101
+ function truncate(value, max) {
102
+ return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
103
+ }
63
104
  //#endregion
64
105
  export { init as default };
@@ -1,3 +1,4 @@
1
+ import { t as withAgent } from "./agent-call.js";
1
2
  import { a as nonEmpty, c as positiveInt, n as compact, r as csv, t as bool } from "./query-utils.js";
2
3
  //#region src/nodes/tasks-list.ts
3
4
  var init = (RED) => {
@@ -15,9 +16,8 @@ var init = (RED) => {
15
16
  shape: "dot",
16
17
  text: "loading…"
17
18
  });
18
- const agent = await agentNode.getAgent();
19
19
  const query = buildTasksQuery(def, msg);
20
- const result = await agent.tasks.list(query, { teamId });
20
+ const result = await withAgent(agentNode, (agent) => agent.tasks.list(query, { teamId }));
21
21
  const out = RED.util.cloneMessage(msg);
22
22
  out.payload = result.items;
23
23
  out.tasks = {
@@ -1,3 +1,4 @@
1
+ import { t as withAgent } from "./agent-call.js";
1
2
  //#region src/nodes/workflow-status.ts
2
3
  var init = (RED) => {
3
4
  function WorkflowStatusNode(def) {
@@ -17,12 +18,11 @@ var init = (RED) => {
17
18
  });
18
19
  const teamId = agentNode.teamId;
19
20
  if (!teamId) throw new Error("workflow-status: agent teamId is required");
20
- const agent = await agentNode.getAgent();
21
21
  const query = {
22
22
  correlationId,
23
23
  limit: def.limit && def.limit > 0 ? def.limit : 50
24
24
  };
25
- const res = await agent.tasks.list(query, { teamId });
25
+ const res = await withAgent(agentNode, (agent) => agent.tasks.list(query, { teamId }));
26
26
  const rows = res.items.map((t) => ({
27
27
  taskId: t.id,
28
28
  type: t.taskType,