@growthub/cli 0.14.0 → 0.14.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.
Files changed (23) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +99 -2
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +1 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +70 -9
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceHelperSetupModal.jsx +1 -1
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/AgentSwarmPanel.jsx +61 -35
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +18 -4
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +264 -22
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +81 -10
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +70 -85
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SidecarExpandView.jsx +37 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/SwarmRunCockpit.jsx +625 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +150 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +129 -3
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +48 -9
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-agent-host.js +139 -4
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +4 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-agent-swarm.js +246 -4
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph.js +6 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-run-console.js +411 -1
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +23 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-store.js +8 -6
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-swarm-proposal.js +551 -0
  23. package/package.json +1 -1
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Helper slash-command registry (SWARM_RUN_CONTRACT_V1, Phase 6).
3
+ *
4
+ * Pure module — no React, no fetch, no config writes. The HelperSidecar
5
+ * composer consumes this registry to render the "/" menu; unit tests assert
6
+ * its governance invariants.
7
+ *
8
+ * Governance rules encoded here:
9
+ * - read-only commands (mutates: false) may switch the sidecar view or
10
+ * seed a prompt — they never create proposals or patch config.
11
+ * - mutating commands (mutates: true) only ever SEED a helper proposal
12
+ * request (intent + prompt template). The proposal still travels the
13
+ * full governed chain: helper query → review → helper/apply → receipt.
14
+ * - no command executes sandbox-run directly and no command patches
15
+ * workspace config directly.
16
+ */
17
+
18
+ export const HELPER_COMMANDS = [
19
+ {
20
+ name: "/goal",
21
+ label: "Goal",
22
+ description: "Set a verifiable goal for this helper session",
23
+ scope: "chat",
24
+ mutates: false,
25
+ promptTemplate: "Set a verifiable goal for this helper session:"
26
+ },
27
+ {
28
+ name: "/loop",
29
+ label: "Loop",
30
+ description: "Propose a governed recurring loop — reviewed before anything runs",
31
+ scope: "workspace",
32
+ mutates: true,
33
+ promptTemplate: "Propose a governed loop:"
34
+ },
35
+ {
36
+ name: "/workflows",
37
+ label: "Workflows",
38
+ description: "Open the background tasks list — read-only, no writes",
39
+ scope: "workspace",
40
+ mutates: false,
41
+ view: "swarm-list"
42
+ },
43
+ {
44
+ name: "/swarm",
45
+ label: "Swarm",
46
+ description: "Propose a governed agent swarm — you review and apply before any run",
47
+ scope: "swarm",
48
+ mutates: true,
49
+ intent: "swarm",
50
+ promptTemplate: "Propose a governed agent swarm:"
51
+ },
52
+ {
53
+ name: "/register-api",
54
+ label: "Register API",
55
+ description: "Draft an API Registry entry as a reviewable proposal",
56
+ scope: "workspace",
57
+ mutates: true,
58
+ intent: "register_api"
59
+ },
60
+ {
61
+ name: "/create-object",
62
+ label: "Create object",
63
+ description: "Translate a plain-language description into a new business object",
64
+ scope: "workspace",
65
+ mutates: true,
66
+ intent: "create_object"
67
+ }
68
+ ];
69
+
70
+ // The full behavioral surface a command may declare. Anything outside this
71
+ // list (execute hooks, patch fields, fetch targets…) is a governance
72
+ // violation — commands are a keyboard front-end to the existing pill
73
+ // intent system, never an action runner.
74
+ export const HELPER_COMMAND_ALLOWED_KEYS = [
75
+ "name",
76
+ "label",
77
+ "description",
78
+ "scope",
79
+ "mutates",
80
+ "promptTemplate",
81
+ "view",
82
+ "intent"
83
+ ];
84
+
85
+ /**
86
+ * Pure governance validator for one command entry. Used by the unit suite
87
+ * against the live registry AND against forged entries (proving the
88
+ * invariant bites). Returns { ok, error }.
89
+ */
90
+ export function isGovernedHelperCommand(cmd) {
91
+ if (!cmd || typeof cmd !== "object") return { ok: false, error: "command must be an object" };
92
+ if (typeof cmd.name !== "string" || !cmd.name.startsWith("/")) {
93
+ return { ok: false, error: "command name must start with /" };
94
+ }
95
+ if (typeof cmd.label !== "string" || !cmd.label) return { ok: false, error: `${cmd.name}: label required` };
96
+ if (typeof cmd.mutates !== "boolean") return { ok: false, error: `${cmd.name}: mutates must be declared` };
97
+ for (const key of Object.keys(cmd)) {
98
+ if (!HELPER_COMMAND_ALLOWED_KEYS.includes(key)) {
99
+ return { ok: false, error: `${cmd.name}: behavior key "${key}" is outside the governed surface` };
100
+ }
101
+ }
102
+ if (cmd.mutates) {
103
+ if (!cmd.intent && !cmd.promptTemplate) {
104
+ return { ok: false, error: `${cmd.name}: mutating commands must seed a governed proposal request` };
105
+ }
106
+ if (cmd.view) {
107
+ return { ok: false, error: `${cmd.name}: mutating commands must not switch views directly` };
108
+ }
109
+ }
110
+ return { ok: true, error: null };
111
+ }
112
+
113
+ /**
114
+ * Fuzzy-filter the registry against what the user typed after "/".
115
+ * Matches subsequences against the command name and label so "/wf" hits
116
+ * "/workflows" and "swm" hits "/swarm". Empty query returns everything.
117
+ */
118
+ export function matchHelperCommands(query, commands = HELPER_COMMANDS) {
119
+ const text = String(query || "").trim().toLowerCase().replace(/^\//, "");
120
+ if (!text) return commands.slice();
121
+ const isSubsequence = (needle, haystack) => {
122
+ let i = 0;
123
+ for (const ch of haystack) {
124
+ if (ch === needle[i]) i += 1;
125
+ if (i >= needle.length) return true;
126
+ }
127
+ return needle.length === 0;
128
+ };
129
+ return commands.filter((cmd) => {
130
+ const name = cmd.name.toLowerCase().replace(/^\//, "");
131
+ const label = cmd.label.toLowerCase();
132
+ return name.includes(text) || label.includes(text)
133
+ || isSubsequence(text, name) || isSubsequence(text, label);
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Parse a composer value into a slash-menu state. The menu only engages
139
+ * when "/" is the FIRST character of the prompt — a slash mid-sentence
140
+ * (URLs, paths) never hijacks typing.
141
+ */
142
+ export function parseSlashInput(value) {
143
+ const text = String(value || "");
144
+ if (!text.startsWith("/")) return { active: false, query: "", matches: [] };
145
+ // Once whitespace follows the command token the user is writing the
146
+ // body — keep the menu closed.
147
+ const token = text.slice(1);
148
+ if (/\s/.test(token)) return { active: false, query: "", matches: [] };
149
+ return { active: true, query: token, matches: matchHelperCommands(token) };
150
+ }
@@ -5435,7 +5435,7 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
5435
5435
  min-height: 520px;
5436
5436
  align-items: center;
5437
5437
  justify-content: center;
5438
- padding: 64px 24px;
5438
+ padding: 96px 24px;
5439
5439
  border: 0;
5440
5440
  border-radius: 0;
5441
5441
  background-color: #fff;
@@ -5443,6 +5443,12 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
5443
5443
  background-size: 20px 20px;
5444
5444
  box-shadow: none;
5445
5445
  overflow: hidden;
5446
+ cursor: grab;
5447
+ touch-action: none;
5448
+ user-select: none;
5449
+ }
5450
+ .dm-workflow-orchestration .dm-orchestration-canvas:active {
5451
+ cursor: grabbing;
5446
5452
  }
5447
5453
  .dm-workflow-orchestration .dm-orchestration-canvas__badge {
5448
5454
  top: 16px;
@@ -5498,8 +5504,11 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
5498
5504
  display: flex;
5499
5505
  flex-direction: column;
5500
5506
  align-items: center;
5501
- transform-origin: center top;
5507
+ padding: 96px 0;
5508
+ transform-origin: center center;
5502
5509
  transition: transform .12s ease;
5510
+ will-change: transform;
5511
+ cursor: default;
5503
5512
  }
5504
5513
  .dm-workflow-orchestration .dm-orchestration-canvas__step {
5505
5514
  width: 173px;
@@ -5737,6 +5746,58 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
5737
5746
  padding: 7px 9px;
5738
5747
  box-sizing: border-box;
5739
5748
  }
5749
+ .dm-workflow-orchestration .dm-orchestration-config__field input[type="number"]::-webkit-outer-spin-button,
5750
+ .dm-workflow-orchestration .dm-orchestration-config__field input[type="number"]::-webkit-inner-spin-button {
5751
+ -webkit-appearance: none;
5752
+ margin: 0;
5753
+ }
5754
+ .dm-workflow-orchestration .dm-orchestration-config__field input[type="number"] {
5755
+ appearance: textfield;
5756
+ -moz-appearance: textfield;
5757
+ }
5758
+ .dm-workflow-orchestration .dm-workflow-check {
5759
+ width: fit-content;
5760
+ display: inline-flex;
5761
+ grid-template-columns: none;
5762
+ align-items: center;
5763
+ gap: 7px;
5764
+ min-height: 26px;
5765
+ cursor: pointer;
5766
+ }
5767
+ .dm-workflow-orchestration .dm-workflow-check input[type="checkbox"] {
5768
+ position: absolute;
5769
+ width: 1px;
5770
+ height: 1px;
5771
+ opacity: 0;
5772
+ pointer-events: none;
5773
+ }
5774
+ .dm-workflow-orchestration .dm-workflow-check__box {
5775
+ width: 16px;
5776
+ height: 16px;
5777
+ display: inline-grid;
5778
+ place-items: center;
5779
+ flex: 0 0 16px;
5780
+ border: 1px solid #d1d5db;
5781
+ border-radius: 4px;
5782
+ background: #f8fafc;
5783
+ color: #111827;
5784
+ box-sizing: border-box;
5785
+ }
5786
+ .dm-workflow-orchestration .dm-workflow-check:hover .dm-workflow-check__box {
5787
+ border-color: #9ca3af;
5788
+ background: #fff;
5789
+ }
5790
+ .dm-workflow-orchestration .dm-workflow-check input[type="checkbox"]:checked + .dm-workflow-check__box {
5791
+ border-color: #111827;
5792
+ background: #fff;
5793
+ }
5794
+ .dm-workflow-orchestration .dm-workflow-check input[type="checkbox"]:focus-visible + .dm-workflow-check__box {
5795
+ outline: 2px solid #d1d5db;
5796
+ outline-offset: 2px;
5797
+ }
5798
+ .dm-workflow-orchestration .dm-workflow-check input[type="checkbox"]:disabled + .dm-workflow-check__box {
5799
+ opacity: .55;
5800
+ }
5740
5801
  .dm-workflow-orchestration .dm-orchestration-config__field input:focus,
5741
5802
  .dm-workflow-orchestration .dm-orchestration-config__field textarea:focus,
5742
5803
  .dm-workflow-orchestration .dm-orchestration-config__field select:focus {
@@ -6068,6 +6129,7 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
6068
6129
  .dm-orch-modal-summary span { display: block; font-size: 10px; font-weight: 700; text-transform: uppercase; color: #6b7280; margin-bottom: 4px; }
6069
6130
  .dm-orch-modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid #edf0f3; }
6070
6131
  .dm-record-drawer-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid #edf0f3; }
6132
+ .dm-record-drawer-head > div:first-child { min-width: 0; flex: 1 1 auto; }
6071
6133
  .dm-record-drawer-actions { display: inline-flex; align-items: center; gap: 8px; }
6072
6134
  .dm-record-head-run { min-height: 30px; padding: 0 11px; }
6073
6135
  .dm-record-drawer-actions { display: inline-flex; align-items: center; gap: 8px; }
@@ -6079,7 +6141,7 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
6079
6141
  .dm-drawer-hidden-fields > span { color: #64748b; font-size: 12px; font-weight: 600; }
6080
6142
  .dm-drawer-hidden-list { display: flex; flex-wrap: wrap; gap: 8px; }
6081
6143
  .dm-record-drawer-head p { margin: 0 0 3px; color: #94a3b8; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
6082
- .dm-record-drawer-head h2 { margin: 0; color: #111827; font-size: 16px; font-weight: 650; }
6144
+ .dm-record-drawer-head h2 { margin: 0; color: #111827; font-size: 16px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
6083
6145
  .dm-record-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 0 0 28px; }
6084
6146
  .dm-record-testbar { display: flex; align-items: center; gap: 8px; padding: 10px 18px; border-bottom: 1px solid #edf0f3; background: #fbfdff; }
6085
6147
  .dm-record-testbar > span:last-child { min-width: 0; color: #64748b; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -6188,6 +6250,7 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
6188
6250
  .dm-drawer-section.open .dm-drawer-section-toggle svg { transform: rotate(90deg); }
6189
6251
  .dm-drawer-section-body { display: grid; gap: 10px; padding: 11px; }
6190
6252
  .dm-sandbox-config { display: grid; gap: 8px; }
6253
+ .dm-sandbox-config > .dm-cockpit { width: 100%; margin: 12px 0 8px; box-sizing: border-box; }
6191
6254
  .dm-radio-row { display: grid; gap: 8px; }
6192
6255
  .dm-radio-row label, .dm-check-row { display: grid; grid-template-columns: 16px minmax(0,1fr); align-items: start; column-gap: 8px; color: #1f2937; font-size: 12px; line-height: 1.35; }
6193
6256
  .dm-radio-row input[type="radio"], .dm-check-row input[type="checkbox"] { width: 14px; height: 14px; margin: 1px 0 0; padding: 0; box-shadow: none; accent-color: #111827; }
@@ -8746,6 +8809,10 @@ body.workspace-rail-collapsed .workspace-builder.dm-workflow-page {
8746
8809
  .dm-run-console__tree-dot[data-variant="fail"] { background: #dc2626; }
8747
8810
  .dm-run-console__tree-dot[data-variant="active"] { background: #2563eb; }
8748
8811
  .dm-run-console__tree-dot[data-variant="canceled"] { background: #9ca3af; }
8812
+ /* Pending (hollow) — the ONE sanctioned swarm-cockpit grammar addition:
8813
+ "declared but not started" reads as an outline in the SAME grey token the
8814
+ canceled/base dot already uses. No new color values. */
8815
+ .dm-run-console__tree-dot[data-variant="pending"] { background: transparent; border: 1px solid #9ca3af; }
8749
8816
  .dm-run-console__tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
8750
8817
  .dm-run-console__tree-meta { color: #6b7280; font-size: 10px; font-variant-numeric: tabular-nums; }
8751
8818
  .dm-run-console__tree-bar { display: block; width: 56px; height: 4px; background: #e5e7eb; border-radius: 999px; overflow: hidden; }
@@ -9114,3 +9181,62 @@ body.workspace-rail-collapsed .workspace-builder.workspace-lens-page,
9114
9181
  .workspace-template-context-banner { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 6px; background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af; font-size: 12px; margin: 0 0 12px; }
9115
9182
  .workspace-template-context-banner.is-warn { background: #fffbeb; border-color: #fde68a; color: #92400e; }
9116
9183
  .workspace-template-context-banner .workspace-template-context-link { color: inherit; text-decoration: underline; font-weight: 600; margin-left: auto; }
9184
+
9185
+ /* ---------------------------------------------------------------------------
9186
+ Governed Swarm Cockpit (SWARM_RUN_CONTRACT_V1) — structural layout ONLY.
9187
+ No new colors, borders, backgrounds, icons, or motion: every visual
9188
+ treatment comes from existing primitives composed in the JSX —
9189
+ dm-helper-toolcall(-row/-title/-chevron/-body/-json), dm-helper-stream,
9190
+ dm-helper-error, dm-run-console__hint, dm-run-console__tree-dot,
9191
+ dm-btn-ghost, dm-sidecar-header, dm-helper-pill-menu. The dm-swarm-*
9192
+ classes below carry layout, spacing, grid, overflow, and typography
9193
+ weight/size exclusively. */
9194
+
9195
+ /* Slash command menu — same dm-helper-pill-menu primitive, anchored above
9196
+ the composer textarea instead of below a pill. */
9197
+ .dm-helper-composer-input { position: relative; }
9198
+ .dm-helper-slash-menu { bottom: calc(100% + 6px); top: auto; left: 0; right: 0; }
9199
+ .dm-helper-slash-menu .dm-helper-pill-menu-item { justify-content: space-between; gap: 10px; }
9200
+ .dm-helper-slash-menu .dm-field-hint { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
9201
+ .dm-helper-slash-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
9202
+
9203
+ /* Cockpit body — scrolling list inside the existing sidecar body. */
9204
+ .dm-swarm-body { display: flex; flex-direction: column; min-height: 0; }
9205
+ .dm-swarm-cockpit { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px 16px; display: flex; flex-direction: column; gap: 10px; }
9206
+ .dm-swarm-cockpit-list { display: flex; flex-direction: column; gap: 8px; }
9207
+ .dm-swarm-section-row { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
9208
+
9209
+ /* Run card — surface chrome comes from dm-helper-toolcall in the JSX. */
9210
+ .dm-swarm-card { padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
9211
+ .dm-swarm-card-head { display: flex; align-items: center; gap: 8px; }
9212
+ .dm-swarm-card-title { flex: 1; }
9213
+ .dm-swarm-card-action { height: 24px; padding: 0 7px; }
9214
+ .dm-swarm-card-meta { display: flex; align-items: center; gap: 10px; }
9215
+ .dm-swarm-card-meta .dm-run-console__hint { font-size: 12px; }
9216
+ .dm-swarm-card-kind { font-weight: 600; }
9217
+ .dm-swarm-card-desc { font-size: 12px; padding: 7px 9px; }
9218
+
9219
+ /* Phase groups — dm-helper-toolcall card + dm-helper-toolcall-row head. */
9220
+ .dm-swarm-phases { display: flex; flex-direction: column; gap: 6px; margin-top: 2px; }
9221
+ .dm-swarm-phase { display: flex; flex-direction: column; }
9222
+ .dm-swarm-phase-head { grid-template-columns: 1fr auto; min-height: 30px; padding: 6px 10px; }
9223
+ .dm-swarm-dotstrip { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding: 0 10px 8px; }
9224
+
9225
+ /* Agent table — Agent | Tokens | Tools | Time, inside dm-helper-toolcall-body. */
9226
+ .dm-swarm-agent-table { display: flex; flex-direction: column; gap: 1px; }
9227
+ .dm-swarm-agent-row { display: grid; grid-template-columns: minmax(0, 1fr) 56px 44px 48px; align-items: center; gap: 6px; padding: 3px 4px; font: inherit; font-size: 12px; background: transparent; border: 0; cursor: pointer; text-align: left; }
9228
+ .dm-swarm-agent-header { cursor: default; }
9229
+ .dm-swarm-agent-name { display: flex; align-items: center; gap: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
9230
+ .dm-swarm-agent-cell { text-align: right; font-variant-numeric: tabular-nums; }
9231
+
9232
+ /* Transcript drill-in — existing tool-call JSON pre is the output frame. */
9233
+ .dm-swarm-transcript { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
9234
+ .dm-swarm-transcript-head { display: flex; align-items: center; justify-content: space-between; }
9235
+ .dm-swarm-transcript-expand { align-self: flex-end; margin-top: 4px; }
9236
+
9237
+ /* Expand view — full-width takeover within the same sidecar shell; the
9238
+ head reuses dm-sidecar-header chrome. */
9239
+ .dm-swarm-expand { display: flex; flex-direction: column; min-height: 0; flex: 1; }
9240
+ .dm-swarm-expand-head { justify-content: flex-start; gap: 8px; }
9241
+ .dm-swarm-expand-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px; }
9242
+ .dm-swarm-expand-body .dm-helper-toolcall-json { max-height: none; }
@@ -4,10 +4,10 @@ import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import Link from "next/link";
5
5
  import { useRouter, useSearchParams } from "next/navigation";
6
6
  import {
7
+ ArrowDown,
8
+ ArrowUp,
7
9
  ArrowUpCircle,
8
10
  Bot,
9
- ChevronDown,
10
- ChevronUp,
11
11
  Code,
12
12
  Filter,
13
13
  FormInput,
@@ -135,6 +135,29 @@ function patchSandboxRowInConfig(workspaceConfig, objectId, rowIndex, fields) {
135
135
  };
136
136
  }
137
137
 
138
+ function nodeSandboxRecordRef(objectId, rowName, nodeId) {
139
+ return {
140
+ objectId: String(objectId || "").trim(),
141
+ rowName: String(rowName || "").trim(),
142
+ nodeId: String(nodeId || "").trim()
143
+ };
144
+ }
145
+
146
+ function withGraphSandboxRecordRefs(graph, objectId, rowName) {
147
+ const parsed = parseOrchestrationGraph(graph) || graph;
148
+ if (!parsed || typeof parsed !== "object") return parsed;
149
+ return {
150
+ ...parsed,
151
+ nodes: (Array.isArray(parsed.nodes) ? parsed.nodes : []).map((node) => ({
152
+ ...node,
153
+ config: {
154
+ ...(node?.config || {}),
155
+ sandboxRecordRef: nodeSandboxRecordRef(objectId, rowName, node?.id)
156
+ }
157
+ }))
158
+ };
159
+ }
160
+
138
161
  const WORKFLOW_ACTION_GROUPS = [
139
162
  {
140
163
  label: "Data",
@@ -243,6 +266,7 @@ function getNodeDeltaRecords(previousGraph, nextGraph) {
243
266
  nodeId,
244
267
  nodeType: String(node?.type || ""),
245
268
  label: String(node?.label || node?.sandbox || nodeId),
269
+ sandboxRecordRef: config.sandboxRecordRef || null,
246
270
  changeReason,
247
271
  deltaTags,
248
272
  requiresRetest: config.requiresRetest !== false,
@@ -497,8 +521,16 @@ export default function WorkflowSurface() {
497
521
 
498
522
  const selectedNode = useMemo(() => {
499
523
  if (!orchestrationGraph?.nodes || !selectedNodeId) return null;
500
- return orchestrationGraph.nodes.find((n) => String(n.id) === selectedNodeId) || null;
501
- }, [orchestrationGraph, selectedNodeId]);
524
+ const node = orchestrationGraph.nodes.find((n) => String(n.id) === selectedNodeId) || null;
525
+ if (!node) return null;
526
+ return {
527
+ ...node,
528
+ config: {
529
+ ...(node.config || {}),
530
+ sandboxRecordRef: nodeSandboxRecordRef(objectId, rowId, node.id)
531
+ }
532
+ };
533
+ }, [orchestrationGraph, selectedNodeId, objectId, rowId]);
502
534
 
503
535
  useEffect(() => {
504
536
  if (graphUnset || graphBlankShell) {
@@ -521,7 +553,7 @@ export default function WorkflowSurface() {
521
553
  }
522
554
 
523
555
  function serializeCurrentGraph() {
524
- return graphUnset ? "" : serializeOrchestrationGraph(orchestrationGraph);
556
+ return graphUnset ? "" : serializeOrchestrationGraph(withGraphSandboxRecordRefs(orchestrationGraph, objectId, rowId));
525
557
  }
526
558
 
527
559
  async function saveDraft(extraFields = {}) {
@@ -588,7 +620,8 @@ export default function WorkflowSurface() {
588
620
  const nextVersion = Number.isFinite(currentVersion) ? String(currentVersion + 1) : "1";
589
621
  const previousDeltas = Array.isArray(sandboxRow?.orchestrationDeltas) ? sandboxRow.orchestrationDeltas : [];
590
622
  const previousPublishedGraph = parseOrchestrationGraph(sandboxRow?.[effectiveFieldName]);
591
- const nodeDeltas = getNodeDeltaRecords(previousPublishedGraph, orchestrationGraph);
623
+ const graphWithRefs = withGraphSandboxRecordRefs(orchestrationGraph, objectId, rowId);
624
+ const nodeDeltas = getNodeDeltaRecords(previousPublishedGraph, graphWithRefs);
592
625
  const deltaTags = normalizeDeltaTags(nodeDeltas.flatMap((delta) => delta.deltaTags));
593
626
  const changeReason = nodeDeltas.map((delta) => delta.changeReason).filter(Boolean).join("\n");
594
627
  const next = patchSandboxRowInConfig(workspaceConfig, objectId, resolved.rowIndex, {
@@ -798,8 +831,12 @@ export default function WorkflowSurface() {
798
831
  function handleNodeConfigChange(configPatch) {
799
832
  if (!selectedNodeId) return;
800
833
  const { __nodePatch, ...configOnly } = configPatch || {};
834
+ const recordRef = nodeSandboxRecordRef(objectId, rowId, selectedNodeId);
801
835
  setOrchestrationGraph((g) => {
802
- const updated = updateGraphNode(g, selectedNodeId, configOnly);
836
+ const updated = updateGraphNode(g, selectedNodeId, {
837
+ ...configOnly,
838
+ sandboxRecordRef: recordRef
839
+ });
803
840
  if (!__nodePatch || typeof __nodePatch !== "object") return updated;
804
841
  const parsed = parseOrchestrationGraph(updated) || updated;
805
842
  return {
@@ -960,7 +997,7 @@ export default function WorkflowSurface() {
960
997
  setAddTarget(null);
961
998
  }}
962
999
  >
963
- <ChevronDown size={14} />
1000
+ <ArrowDown size={13} />
964
1001
  </button>
965
1002
  <button
966
1003
  type="button"
@@ -974,7 +1011,7 @@ export default function WorkflowSurface() {
974
1011
  setAddTarget(null);
975
1012
  }}
976
1013
  >
977
- <ChevronUp size={14} />
1014
+ <ArrowUp size={13} />
978
1015
  </button>
979
1016
  {sandboxRow && (
980
1017
  <button
@@ -1194,6 +1231,8 @@ export default function WorkflowSurface() {
1194
1231
  </div>
1195
1232
  <AgentSwarmPanel
1196
1233
  graph={orchestrationGraph}
1234
+ objectId={objectId}
1235
+ rowName={rowId}
1197
1236
  disabled={false}
1198
1237
  onGraphChange={(updater) => {
1199
1238
  setOrchestrationGraph((g) => (typeof updater === "function" ? updater(g) : updater));
@@ -34,6 +34,7 @@ import path from "node:path";
34
34
  import { registerSandboxAdapter } from "./sandbox-adapter-registry.js";
35
35
 
36
36
  const MAX_OUTPUT_BYTES = 1024 * 256;
37
+ const TELEMETRY_MARKER = "GROWTHUB_AGENT_TELEMETRY:";
37
38
 
38
39
  /**
39
40
  * Canonical Paperclip host catalog — slugs mirror `AGENT_ADAPTER_TYPES`.
@@ -118,6 +119,134 @@ function clampStream(buffer) {
118
119
  return `${head.toString("utf8")}\n…\n[output truncated at ${MAX_OUTPUT_BYTES} bytes]`;
119
120
  }
120
121
 
122
+ function safeNonNegativeInt(value) {
123
+ if (value === null || value === undefined || value === "") return null;
124
+ const n = Number(value);
125
+ if (!Number.isFinite(n) || n < 0) return null;
126
+ return Math.floor(n);
127
+ }
128
+
129
+ function pickFirstNumber(...values) {
130
+ for (const value of values) {
131
+ const n = safeNonNegativeInt(value);
132
+ if (n != null) return n;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function sumNumbers(...values) {
138
+ let total = 0;
139
+ let seen = false;
140
+ for (const value of values) {
141
+ const n = safeNonNegativeInt(value);
142
+ if (n == null) continue;
143
+ total += n;
144
+ seen = true;
145
+ }
146
+ return seen ? total : null;
147
+ }
148
+
149
+ function extractUsageFromObject(obj) {
150
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return { tokens: null, tools: null };
151
+ const usage = (obj.usage && typeof obj.usage === "object" && !Array.isArray(obj.usage))
152
+ ? obj.usage
153
+ : (obj.token_usage && typeof obj.token_usage === "object" && !Array.isArray(obj.token_usage))
154
+ ? obj.token_usage
155
+ : (obj.metadata?.usage && typeof obj.metadata.usage === "object" && !Array.isArray(obj.metadata.usage))
156
+ ? obj.metadata.usage
157
+ : (obj.result?.usage && typeof obj.result.usage === "object" && !Array.isArray(obj.result.usage))
158
+ ? obj.result.usage
159
+ : null;
160
+ const tokens = usage
161
+ ? pickFirstNumber(
162
+ usage.total_tokens,
163
+ usage.totalTokens,
164
+ usage.tokens,
165
+ sumNumbers(usage.input_tokens, usage.output_tokens),
166
+ sumNumbers(usage.prompt_tokens, usage.completion_tokens),
167
+ sumNumbers(usage.inputTokens, usage.outputTokens),
168
+ )
169
+ : pickFirstNumber(obj.total_tokens, obj.totalTokens, obj.tokens);
170
+ const toolArrays = [
171
+ obj.tool_calls,
172
+ obj.toolCalls,
173
+ obj.toolInvocations,
174
+ obj.message?.tool_calls,
175
+ obj.choices?.[0]?.message?.tool_calls,
176
+ obj.result?.tool_calls,
177
+ obj.result?.toolCalls,
178
+ ].filter(Array.isArray);
179
+ const tools = pickFirstNumber(
180
+ obj.tools,
181
+ obj.tool_count,
182
+ obj.toolCount,
183
+ ...toolArrays.map((items) => items.length),
184
+ );
185
+ return { tokens, tools };
186
+ }
187
+
188
+ function mergeTelemetry(base, next) {
189
+ return {
190
+ tokens: base.tokens ?? next.tokens ?? null,
191
+ tools: base.tools ?? next.tools ?? null,
192
+ };
193
+ }
194
+
195
+ function parseJsonMaybe(text) {
196
+ const value = String(text || "").trim();
197
+ if (!value || !/^[\[{]/.test(value)) return null;
198
+ try {
199
+ return JSON.parse(value);
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ function extractMarkedTelemetry(text) {
206
+ let out = { tokens: null, tools: null };
207
+ for (const line of String(text || "").split(/\r?\n/)) {
208
+ const idx = line.indexOf(TELEMETRY_MARKER);
209
+ if (idx === -1) continue;
210
+ const json = line.slice(idx + TELEMETRY_MARKER.length).trim();
211
+ const parsed = parseJsonMaybe(json);
212
+ out = mergeTelemetry(out, extractUsageFromObject(parsed));
213
+ }
214
+ return out;
215
+ }
216
+
217
+ function extractJsonLineTelemetry(text) {
218
+ let out = { tokens: null, tools: null };
219
+ for (const line of String(text || "").split(/\r?\n/)) {
220
+ const parsed = parseJsonMaybe(line);
221
+ if (!parsed) continue;
222
+ out = mergeTelemetry(out, extractUsageFromObject(parsed));
223
+ }
224
+ return out;
225
+ }
226
+
227
+ function extractStderrTextTelemetry(stderrText) {
228
+ const text = String(stderrText || "");
229
+ const total = text.match(/\b(?:total\s+tokens|tokens\s+used)\s*(?:[:=]|\r?\n)\s*([0-9][0-9,]*)/i);
230
+ const input = text.match(/\b(?:input|prompt)\s+tokens\s*[:=]\s*([0-9][0-9,]*)/i);
231
+ const output = text.match(/\b(?:output|completion)\s+tokens\s*[:=]\s*([0-9][0-9,]*)/i);
232
+ const tools = text.match(/\b(?:tool\s+calls?|tools\s+used)\s*[:=]\s*([0-9][0-9,]*)/i);
233
+ const tokens = pickFirstNumber(total?.[1]?.replace(/,/g, ""), sumNumbers(input?.[1]?.replace(/,/g, ""), output?.[1]?.replace(/,/g, "")));
234
+ return {
235
+ tokens,
236
+ tools: pickFirstNumber(tools?.[1]?.replace(/,/g, ""), tokens != null ? 0 : null),
237
+ };
238
+ }
239
+
240
+ function extractAgentHostTelemetry({ stdout, stderr }) {
241
+ let out = { tokens: null, tools: null };
242
+ const stdoutJson = parseJsonMaybe(stdout);
243
+ if (stdoutJson && !Array.isArray(stdoutJson)) out = mergeTelemetry(out, extractUsageFromObject(stdoutJson));
244
+ out = mergeTelemetry(out, extractMarkedTelemetry(stderr));
245
+ out = mergeTelemetry(out, extractJsonLineTelemetry(stderr));
246
+ out = mergeTelemetry(out, extractStderrTextTelemetry(stderr));
247
+ return out;
248
+ }
249
+
121
250
  async function run(request) {
122
251
  const hostSlug = typeof request.agentHost === "string" ? request.agentHost.trim() : "";
123
252
  const host = HOST_CATALOG[hostSlug];
@@ -238,12 +367,15 @@ async function run(request) {
238
367
  clearTimeout(timer);
239
368
  const durationMs = Date.now() - startedAt;
240
369
  const ok = !timedOut && exitCode === 0;
370
+ const stdoutText = clampStream(stdout);
371
+ const stderrText = clampStream(stderr);
372
+ const telemetry = extractAgentHostTelemetry({ stdout: stdoutText, stderr: stderrText });
241
373
  resolve({
242
374
  ok,
243
375
  exitCode: typeof exitCode === "number" ? exitCode : null,
244
376
  durationMs,
245
- stdout: clampStream(stdout),
246
- stderr: clampStream(stderr),
377
+ stdout: stdoutText,
378
+ stderr: stderrText,
247
379
  error: timedOut
248
380
  ? `timed out after ${timeoutMs}ms`
249
381
  : (ok ? undefined : `exit ${exitCode ?? signal ?? "unknown"}`),
@@ -254,7 +386,10 @@ async function run(request) {
254
386
  argv,
255
387
  inputMode: host.inputMode,
256
388
  timedOut,
257
- signal: signal || null
389
+ signal: signal || null,
390
+ tokens: telemetry.tokens,
391
+ tools: telemetry.tools,
392
+ telemetrySource: telemetry.tokens != null || telemetry.tools != null ? "agent-host-reported" : "unreported"
258
393
  }
259
394
  });
260
395
  });
@@ -281,4 +416,4 @@ registerSandboxAdapter({
281
416
  run
282
417
  });
283
418
 
284
- export { HOST_CATALOG, SUPPORTED_HOSTS };
419
+ export { HOST_CATALOG, SUPPORTED_HOSTS, extractAgentHostTelemetry };
@@ -190,6 +190,10 @@ async function run(request) {
190
190
  endpoint,
191
191
  model,
192
192
  locality: "local",
193
+ // Truthful telemetry only — taken from the completion's usage block
194
+ // when the endpoint reports one, never estimated. Null means unknown.
195
+ tokens: Number.isFinite(outer?.usage?.total_tokens) ? outer.usage.total_tokens : null,
196
+ tools: Array.isArray(parsed.toolIntents) ? parsed.toolIntents.length : null,
193
197
  },
194
198
  };
195
199
  } catch (err) {