@unoverse-platform/studio 0.1.115 → 0.1.116

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 (37) hide show
  1. package/local/catalog.ts +3 -0
  2. package/local/plugin.ts +12 -9
  3. package/package.json +1 -1
  4. package/screens/AgentChain.tsx +216 -0
  5. package/screens/AgentsView.tsx +45 -6
  6. package/screens/SkillsView.tsx +21 -3
  7. package/screens/index.ts +3 -0
  8. package/vendor/base/src/agent-mcp/discovery.ts +4 -1
  9. package/vendor/base/src/agent-mcp/invoke.ts +155 -25
  10. package/vendor/base/src/agent-mcp/tools.ts +17 -10
  11. package/vendor/base/src/agent-mcp/types.ts +8 -0
  12. package/vendor/base/src/auth/agentSwitch.ts +52 -0
  13. package/vendor/base/src/auth/agentToken.ts +142 -0
  14. package/vendor/base/src/auth/auth.ts +7 -0
  15. package/vendor/base/src/definitions/load/load.ts +6 -1
  16. package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
  17. package/vendor/base/src/definitions/registry/objects.ts +16 -0
  18. package/vendor/base/src/definitions/registry/pipelines.ts +13 -5
  19. package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
  20. package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
  21. package/vendor/base/src/lint/design/index.mjs +10 -2
  22. package/vendor/base/src/lint/objects/index.d.mts +1 -0
  23. package/vendor/base/src/lint/objects/index.mjs +120 -2
  24. package/vendor/base/src/lint/objects/rules.mjs +16 -5
  25. package/vendor/base/src/manifests/executor/toolBridge.ts +10 -3
  26. package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
  27. package/vendor/base/src/manifests/runtime/http/request.ts +4 -1
  28. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +58 -23
  29. package/vendor/base/src/manifests/runtime/usage.ts +13 -5
  30. package/vendor/base/src/platform/agentBudget.ts +65 -0
  31. package/vendor/base/src/platform/agentCalendar.ts +87 -0
  32. package/vendor/base/src/platform/taskChanged.ts +25 -0
  33. package/vendor/base/src/platform/tokenCost.ts +395 -0
  34. package/vendor/base/src/security/permissions.ts +10 -2
  35. package/vendor/sdk/appEngine.ts +42 -1
  36. package/vendor/sdk/lib/dom/markdown.ts +6 -2
  37. package/vendor/sdk/pageInterfaces.ts +109 -8
@@ -32,8 +32,11 @@ import { makeUsageCollector } from "../usage.js";
32
32
  export interface ToolBridge {
33
33
  /** Core tools from the provider's getSchema, as { name, description, parameters }. */
34
34
  discover(): Promise<any[]>;
35
- /** Execute one call; returns the raw result content. */
36
- call(name: string, args: any): Promise<string>;
35
+ /**
36
+ * Execute one call; returns the raw result content. `onStatus` hears what a called TASK is
37
+ * doing each time its status changes (docs/mcp/MCP_TASKS.md § 5, "what reaches the model").
38
+ */
39
+ call(name: string, args: any, hooks?: { onStatus?: (statusMessage: string) => void }): Promise<string>;
37
40
  /**
38
41
  * Absorb one tool reply: unlock + shell-open any apps it discovered, and hand back the
39
42
  * MODEL-FACING form of it plus any tools to register.
@@ -92,7 +95,7 @@ export function recordToolTrace(
92
95
  * on the same timeline but is not a tool the model chose, so it is told apart by a stated
93
96
  * kind rather than by the viewer sniffing its name.
94
97
  */
95
- kind: "tool" | "skill" | "task" = "tool",
98
+ kind: "tool" | "skill" | "task" | "turn" = "tool",
96
99
  ): void {
97
100
  recordCall({ executionId: (ctx as any)?.scope?.executionId, parentNodeId: (ctx as any)?.scope?.nodeId, toolName, args, result, startTime, success, kind, who: node.type });
98
101
  }
@@ -111,7 +114,7 @@ export function recordCall(call: {
111
114
  result: unknown;
112
115
  startTime: number;
113
116
  success: boolean;
114
- kind?: "tool" | "skill" | "task";
117
+ kind?: "tool" | "skill" | "task" | "turn";
115
118
  who: string;
116
119
  }): void {
117
120
  const { executionId, parentNodeId, toolName, args, result, startTime, success, kind = "tool" } = call;
@@ -245,8 +248,8 @@ export async function runToolLoop(
245
248
  * written, or the held set would keep a skill the conversation has walked away from. */
246
249
  let skillsChanged = false;
247
250
 
248
- // Token usage across ALL turns of this run — each turn is its own model call with its
249
- // own usage block; the collector sums them and posts once when the loop settles.
251
+ // Token usage, posted after EACH turn (each turn is its own model call with its own usage
252
+ // block), so a run's spend is on record while it runs, and once more when the loop settles.
250
253
  const usage = makeUsageCollector(node, ctx);
251
254
 
252
255
  for (turn = 1; turn <= maxTurns; turn++) {
@@ -275,6 +278,9 @@ export async function runToolLoop(
275
278
  // would send each result twice — once as a message and once in a field the vendor ignores.
276
279
  if (results && !te.transcript) extra[te.resultsInto ?? "input"] = results;
277
280
 
281
+ // WHEN THE TURN STARTED, for its own row on the timeline (below): without it the gap between
282
+ // two rounds of tool calls could not be told apart into model time and everything else.
283
+ const turnStart = Date.now();
278
284
  const res = await sendRequest(node, withExtra(modelCall, extra), scoped, `${node.type} (turn ${turn})`);
279
285
 
280
286
  // Two readers of one stream: the events table decides what leaves the node, and the
@@ -291,6 +297,12 @@ export async function runToolLoop(
291
297
  * Chat Completions), and accumulated because it arrives a token at a time.
292
298
  */
293
299
  let spoken = "";
300
+ /**
301
+ * The assistant's OWN REASONING this turn, for a vendor that keeps it only when it is sent back
302
+ * (GLM's preserved thinking). Without it the model rebuilds its whole plan every turn. Read and
303
+ * accumulated exactly as `spoken` is; the manifest says where it arrives and where it goes back.
304
+ */
305
+ let thought = "";
294
306
  await readSse(res, modelCall.terminator, async (payload) => {
295
307
  await assertOk(node, modelCall, payload, `${node.type} (turn ${turn})`);
296
308
  usage.see(payload);
@@ -299,6 +311,10 @@ export async function runToolLoop(
299
311
  const piece = await evaluate(te.transcript.text, { response: payload });
300
312
  if (piece) spoken += String(piece);
301
313
  }
314
+ if (te.transcript?.thinking) {
315
+ const piece = await evaluate(te.transcript.thinking, { response: payload });
316
+ if (piece) thought += String(piece);
317
+ }
302
318
  // `match` is OPTIONAL. It selects by event TYPE, which only works for a vendor whose SSE
303
319
  // events carry one — the Responses API does. A Chat Completions chunk has no `type` at all, so
304
320
  // with no match declared every payload is considered and `when` does the filtering.
@@ -306,6 +322,7 @@ export async function runToolLoop(
306
322
  if (te.continuity && payload.type === te.continuity.match) chainPayload = payload;
307
323
  });
308
324
  if (chainPayload) chainId = String(await evaluate(te.continuity.from, { response: chainPayload }));
325
+ usage.save();
309
326
 
310
327
  // THE TURN IS OVER, SO NOTHING MORE IS COMING TO CARRY A THROTTLE OUT.
311
328
  //
@@ -321,6 +338,9 @@ export async function runToolLoop(
321
338
  emitter.flush();
322
339
 
323
340
  const calls = await readCalls(te, raw);
341
+ // EACH MODEL TURN ON THE TIMELINE, beside the calls it asked for: how long the model took,
342
+ // what it asked for and how much it wrote. Recorded once the turn has streamed in full.
343
+ recordCall({ executionId: (ctx as any)?.scope?.executionId, parentNodeId: (ctx as any)?.scope?.nodeId, toolName: `turn ${turn}`, args: { toolsOffered: live.length }, result: { asked: calls.map((c) => c.name), wroteChars: spoken.length }, startTime: turnStart, success: true, kind: "turn", who: node.type });
324
344
 
325
345
  // Answered rather than called. Done.
326
346
  if (!calls.length) break;
@@ -337,6 +357,8 @@ export async function runToolLoop(
337
357
  const absorbed: string[] = [];
338
358
  let stuck = false;
339
359
 
360
+ // Which calls run: every one before the first repeated too often, in the order asked.
361
+ const toRun: { call: (typeof calls)[number]; args: any }[] = [];
340
362
  for (const call of calls) {
341
363
  const signature = `${call.name}:${call.arguments}`;
342
364
  const count = (seen.get(signature) ?? 0) + 1;
@@ -346,25 +368,38 @@ export async function runToolLoop(
346
368
  stuck = true;
347
369
  break;
348
370
  }
349
-
350
371
  const args = JSON.parse(call.arguments || "{}");
351
372
  narrate({ kind: "toolCall", toolName: call.name, args });
373
+ toRun.push({ call, args });
374
+ }
352
375
 
353
- let content: string;
354
- /**
355
- * THE TIMELINE BAR is the TOOL's to record, never the loop's (owner, 2026-10-01;
356
- * packages/docs/nodes/mcp-services.md, "Who records a call"). Every tool records its
357
- * own call wherever it runs (a node's method, a row's function, a platform tool), with
358
- * `success` false on a failure, so a call reads the same from an Agent, a voice session
359
- * or an outside host. The bar once vanished the day `openai` migrated, because it lived
360
- * in each Agent family; it lives with the tool now.
361
- */
362
- try {
363
- content = await bridge.call(call.name, args);
364
- } catch (err: any) {
365
- // A failed tool is information for the model, not a crash: it can try another.
366
- content = JSON.stringify({ error: err?.message ?? String(err) });
367
- }
376
+ /**
377
+ * THE CALLS OF ONE TURN RUN TOGETHER (UNO-190; docs/mcp/MCP_TASKS.md § 5, Orchestrated).
378
+ * A model that asks for several tools at once wants them side by side: twelve searches
379
+ * fanned out as tasks take as long as the slowest, not the sum. Everything after the call
380
+ * (recording, absorbing, skills in force, minted tools) still goes in the order asked.
381
+ *
382
+ * THE TIMELINE BAR is the TOOL's to record, never the loop's (owner, 2026-10-01;
383
+ * packages/docs/nodes/mcp-services.md, "Who records a call"). Every tool records its
384
+ * own call wherever it runs (a node's method, a row's function, a platform tool), with
385
+ * `success` false on a failure, so a call reads the same from an Agent, a voice session
386
+ * or an outside host. The bar once vanished the day `openai` migrated, because it lived
387
+ * in each Agent family; it lives with the tool now.
388
+ */
389
+ const contents = await Promise.all(
390
+ toRun.map(({ call, args }) =>
391
+ Promise.resolve()
392
+ // A TASK THAT RUNS FOR MINUTES SAYS WHAT IT IS DOING: each new status is narrated,
393
+ // so the caller (a person, a voice) hears progress before the result arrives.
394
+ .then(() => bridge.call(call.name, args, { onStatus: (status) => narrate({ kind: "taskStatus", toolName: call.name, args, status }) }))
395
+ // A failed tool is information for the model, not a crash: it can try another.
396
+ .catch((err: any) => JSON.stringify({ error: err?.message ?? String(err) })),
397
+ ),
398
+ );
399
+
400
+ for (let i = 0; i < toRun.length; i++) {
401
+ const { call, args } = toRun[i];
402
+ const content = contents[i];
368
403
  exchanges.push({ name: call.name, resultContent: content });
369
404
  // The RESULT, and only now that it exists. It is never in the HTTP stream: our loop
370
405
  // produced it, which is why it cannot be a `from: response` row. PARSED for the
@@ -432,7 +467,7 @@ export async function runToolLoop(
432
467
  * vendor's message shape — `te.result` produced them — so they go in as they are.
433
468
  */
434
469
  if (te.transcript) {
435
- if (te.transcript.assistant) transcript.push(await evaluate(te.transcript.assistant, { calls, text: spoken }));
470
+ if (te.transcript.assistant) transcript.push(await evaluate(te.transcript.assistant, { calls, text: spoken, thinking: thought }));
436
471
  transcript.push(...results);
437
472
  }
438
473
  }
@@ -15,8 +15,8 @@
15
15
  * Sniffing those three is a wire-format rule, not a per-node rule — no manifest changes.
16
16
  *
17
17
  * A run may hold SEVERAL model calls (the tool loop's turns), each with its own usage
18
- * block; the collector SUMS numeric fields across them, which is what "this execution
19
- * cost N tokens" means. Fire-and-forget on save, same contract as the MCP trace:
18
+ * block; the collector SUMS numeric fields across what it saw since its last save, and the
19
+ * rows for one execution add up to what it cost. Fire-and-forget on save, same contract as the MCP trace:
20
20
  * observability must never slow a run or fail it.
21
21
  */
22
22
  import type { ComposedNode } from "../compose.js";
@@ -38,7 +38,12 @@ export interface UsageCollector {
38
38
  * the length. A vendor that reports running totals (GPT-Live) would otherwise be summed.
39
39
  */
40
40
  set(field: string, value: number): void;
41
- /** Post the summed usage to the engine. No usage seen, or no execution scope → no-op. */
41
+ /**
42
+ * Post what was seen SINCE THE LAST SAVE to the engine, then start again from nothing. Called
43
+ * after each turn of a tool loop, so spend is on record while the run is still going: a budget
44
+ * read mid-run (an Agent's, AGENTS.md § 9) saw nothing until the loop settled. Nothing new, or no
45
+ * execution scope → no-op.
46
+ */
42
47
  save(): void;
43
48
  }
44
49
 
@@ -61,7 +66,7 @@ function addInto(totals: Record<string, any>, block: Record<string, unknown>): v
61
66
  }
62
67
 
63
68
  export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCollector {
64
- const totals: Record<string, number | Record<string, number>> = {};
69
+ let totals: Record<string, number | Record<string, number>> = {};
65
70
  let seen = false;
66
71
  let model: string | undefined;
67
72
 
@@ -80,6 +85,9 @@ export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCo
80
85
  },
81
86
  save() {
82
87
  if (!seen) return;
88
+ const spent = totals;
89
+ totals = {};
90
+ seen = false;
83
91
  const { workflowId, executionId, nodeId } = ctx.scope ?? {};
84
92
  // Node-test / headless: no execution to attach to (same rule as the MCP trace).
85
93
  if (!workflowId || !executionId || !nodeId) return;
@@ -89,7 +97,7 @@ export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCo
89
97
  nodeId,
90
98
  nodeType: node.type,
91
99
  model: model ?? (ctx.config as any)?.model ?? "unknown",
92
- usage: totals,
100
+ usage: spent,
93
101
  timestamp: new Date().toISOString(),
94
102
  });
95
103
  },
@@ -0,0 +1,65 @@
1
+ /**
2
+ * AN AGENT'S BUDGET (docs/architecture/signal/AGENTS.md § 9, § 9a; UNO-222).
3
+ *
4
+ * Counted, not trusted: what an Agent spent is the platform's own usage rows (`token_usage`) for
5
+ * everything run as the Agent (its own thinking and every task it called), priced by the one table
6
+ * (`tokenCost.ts`). Checked BEFORE every model call and every task, never after:
7
+ *
8
+ * under 80% of a limit go on
9
+ * 80% or more go on, its owner is told once
10
+ * 100% start nothing new
11
+ *
12
+ * Pure: the rows and the limits come in.
13
+ */
14
+ import { parseTokenUsage, calculateTokenCost } from "./tokenCost.js";
15
+
16
+ /** "20 USD", "20", "$20" → 20. Null when it is not an amount in US dollars. */
17
+ export function dollars(amount: string | undefined): number | null {
18
+ if (!amount) return null;
19
+ const m = /^\s*\$?\s*(\d+(?:\.\d+)?)\s*(USD)?\s*$/i.exec(amount);
20
+ return m ? Number(m[1]) : null;
21
+ }
22
+
23
+ /** One usage row as the platform keeps it. */
24
+ export interface UsageRow {
25
+ model: string | null;
26
+ usage: unknown;
27
+ }
28
+
29
+ /** What these rows cost, in US dollars. */
30
+ export function spent(rows: UsageRow[]): number {
31
+ let total = 0;
32
+ for (const row of rows) {
33
+ if (!row.model) continue;
34
+ total += Number(calculateTokenCost(row.model, parseTokenUsage(row.model, row.usage)));
35
+ }
36
+ return Math.round(total * 10000) / 10000;
37
+ }
38
+
39
+ export interface BudgetLimits {
40
+ perDay?: string;
41
+ perRun?: string;
42
+ }
43
+
44
+ export type BudgetCheck =
45
+ | { go: true; near?: "day" | "run"; message?: string }
46
+ | { go: false; limit: "day" | "run" | "none"; message: string };
47
+
48
+ /** May the Agent start one more model call or task? */
49
+ export function checkBudget(limits: BudgetLimits, used: { today: number; run: number }): BudgetCheck {
50
+ const day = dollars(limits.perDay);
51
+ if (day === null) return { go: false, limit: "none", message: "No daily budget: an Agent with no budget.perDay never runs" };
52
+ const run = dollars(limits.perRun);
53
+ if (used.today >= day) return { go: false, limit: "day", message: `Today's budget is spent: $${used.today.toFixed(2)} of $${day.toFixed(2)}. Carries on tomorrow.` };
54
+ if (run !== null && used.run >= run) return { go: false, limit: "run", message: `This run's budget is spent: $${used.run.toFixed(2)} of $${run.toFixed(2)}.` };
55
+ if (used.today >= day * 0.8) return { go: true, near: "day", message: `$${used.today.toFixed(2)} of today's $${day.toFixed(2)} spent` };
56
+ if (run !== null && used.run >= run * 0.8) return { go: true, near: "run", message: `$${used.run.toFixed(2)} of this run's $${run.toFixed(2)} spent` };
57
+ return { go: true };
58
+ }
59
+
60
+ /** Midnight today in the environment's own time zone, where an Agent's day starts. */
61
+ export function startOfToday(now: Date = new Date()): Date {
62
+ const d = new Date(now);
63
+ d.setHours(0, 0, 0, 0);
64
+ return d;
65
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * AN AGENT'S DAY IS ITS CALENDAR (docs/architecture/signal/AGENTS.md § 5; UNO-207).
3
+ *
4
+ * A person drops slots into the Agent's calendar: the title names the slot, the description is
5
+ * what to do. Every `checkIn` the calendar is read; a slot that started since the last look, or is
6
+ * under way now, wakes the Agent, once, with the slot as its instruction: its work is a task from the
7
+ * moment it is on the calendar, never something to wait for. These are the rules; the engine's check-in
8
+ * (`engine/src/agentKeys/checkIn.ts`) reads the calendar and wakes it.
9
+ *
10
+ * Only people in its org give it work (§ 7b): a slot made by the mailbox itself, or by someone at
11
+ * the same company domain. A public mail domain (gmail.com and the like) is nobody's company, so
12
+ * there only the mailbox's own slots count. An outside invitation is never an instruction.
13
+ *
14
+ * A slot names the Agent (owner, 2026-10-10: "start with Bob:"): its title begins with the Agent's
15
+ * name and a colon, "Bob: Morning sweep". Every other event in the calendar is a person's own
16
+ * meeting and never wakes it, which matters while it reads a person's calendar rather than its own.
17
+ */
18
+
19
+ /** The fields of an event these rules read. */
20
+ export interface SlotEvent {
21
+ id: string;
22
+ title: string;
23
+ description: string;
24
+ start: string;
25
+ /** When it ends; empty for none. A slot under way still wakes an Agent that has not worked it. */
26
+ end?: string | null;
27
+ organizer: { address: string } | null;
28
+ creator: { address: string } | null;
29
+ }
30
+
31
+ /** How often its calendar is read when its file says nothing. */
32
+ export const DEFAULT_CHECK_IN_MS = 15 * 60_000;
33
+
34
+ /** "15m", "1h", "30s" → milliseconds; anything else, the default. Never under a minute. */
35
+ export function checkInEvery(checkIn: string | undefined): number {
36
+ const m = /^\s*(\d+)\s*(s|m|h)\s*$/i.exec(checkIn ?? "");
37
+ if (!m) return DEFAULT_CHECK_IN_MS;
38
+ const ms = Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[m[2].toLowerCase() as "s" | "m" | "h"];
39
+ return Math.max(ms, 60_000);
40
+ }
41
+
42
+ const PUBLIC_DOMAINS = new Set(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com", "msn.com", "yahoo.com", "icloud.com", "me.com", "proton.me", "protonmail.com", "aol.com"]);
43
+ const domainOf = (address: string) => address.slice(address.lastIndexOf("@") + 1).toLowerCase();
44
+
45
+ /** Was this slot made by someone in the Agent's org? */
46
+ export function fromOrg(event: SlotEvent, mailbox: string): boolean {
47
+ const maker = (event.creator ?? event.organizer)?.address?.toLowerCase();
48
+ const own = mailbox.trim().toLowerCase();
49
+ if (!maker) return false;
50
+ if (maker === own) return true;
51
+ const domain = domainOf(own);
52
+ return !PUBLIC_DOMAINS.has(domain) && domainOf(maker) === domain;
53
+ }
54
+
55
+ /** "Bob: Morning sweep" for Bob → "Morning sweep"; null when the slot is not addressed to it. */
56
+ export function addressedTo(title: string, agentName: string): string | null {
57
+ const name = agentName.trim().toLowerCase();
58
+ const t = title.trim();
59
+ if (!name || t.slice(0, name.length + 1).toLowerCase() !== `${name}:`) return null;
60
+ return t.slice(name.length + 1).trim();
61
+ }
62
+
63
+ /** The slots to work now, earliest first: started after `since` and by `now`, or started earlier
64
+ * and still under way. Whether one was already worked is the run record's (`slotKey`). */
65
+ export function slotsDue<T extends SlotEvent>(events: T[], since: Date, now: Date): T[] {
66
+ return events
67
+ .filter((e) => {
68
+ const at = Date.parse(e.start);
69
+ if (at > now.getTime()) return false;
70
+ if (at > since.getTime()) return true;
71
+ const end = e.end ? Date.parse(e.end) : NaN;
72
+ return end > now.getTime();
73
+ })
74
+ .sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
75
+ }
76
+
77
+ /** One slot, once: a recurring slot's occurrences differ by start. What its run is recorded as. */
78
+ export function slotKey(event: SlotEvent): string {
79
+ return `calendar:${event.id}@${event.start}`;
80
+ }
81
+
82
+ /** What the Agent is told when a slot starts. */
83
+ export function slotInstruction(event: SlotEvent, agentName: string): string {
84
+ const what = event.description.trim();
85
+ const title = addressedTo(event.title, agentName) || event.title.trim() || "(untitled)";
86
+ return `Your calendar: "${title}" has started.${what ? `\n\n${what}` : "\n\nIt has no description: do your job, as your skills say."}`;
87
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * "A TASK CHANGED" (docs/mcp/MCP_TASKS.md § 5a, S3; step 6). Whatever writes a task's row says
3
+ * so here, once, after the write: the engine when a run starts and ends, the server when a step
4
+ * is saved or a node starts or stops asking. It lives in base because both write rows, in one
5
+ * process; a client following that task (`notifications/tasks`, server/taskListen.ts) is sent the
6
+ * whole task again. Nothing else lives here.
7
+ */
8
+ const listeners = new Set<(taskId: string) => void>();
9
+
10
+ /** Listen for changed tasks. Returns the way to stop listening. */
11
+ export function onTaskChanged(listener: (taskId: string) => void): () => void {
12
+ listeners.add(listener);
13
+ return () => listeners.delete(listener);
14
+ }
15
+
16
+ /** Say a task's row changed. A listener that fails never stops the others. */
17
+ export function taskChanged(taskId: string): void {
18
+ for (const listener of listeners) {
19
+ try {
20
+ listener(taskId);
21
+ } catch {
22
+ /* one entry's failure is its own */
23
+ }
24
+ }
25
+ }