@hue-run/sdk 0.11.1 → 0.11.3

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/CLI.md CHANGED
@@ -42,7 +42,7 @@ project manifest are refused because managers can update ancestor locks; Python
42
42
  The generated `hue.setup.mjs` or `hue_setup.py` always selects `captureContent: false` /
43
43
  `capture_content=False`. For a supported application, setup installs the dependency and adds the
44
44
  managed import and middleware registration to the existing entrypoint; an unreferenced helper is
45
- not a completed integration. TypeScript uses `@hue-run/sdk@0.11.1`, `@opentelemetry/api@1.9.1` and
45
+ not a completed integration. TypeScript uses `@hue-run/sdk@0.11.3`, `@opentelemetry/api@1.9.1` and
46
46
  `@opentelemetry/context-async-hooks@2.11.0`; Python setup uses its separately tested package pin.
47
47
  Content capture requires an ordinary account-managed key and a later explicit application decision.
48
48
 
@@ -168,12 +168,13 @@ accepted too, but Node 22 and 24 read that flag from the whole command line and
168
168
  The env file is written with mode `0600` through a temporary file and an atomic rename. Other
169
169
  lines are preserved; a symlink or a non-regular file is refused; an existing different value is
170
170
  replaced only with `--force`. Empty values, whitespace and URLs are refused before any request.
171
- The MCP endpoint is `https://mcp.hue.run/mcp` for `https://app.hue.run`,
172
- `https://mcp.staging.hue.run/mcp` for `https://staging.hue.run` and `<origin>/api/mcp` otherwise;
173
- plain HTTP origins are accepted for loopback test servers only. Output names variables and lengths
174
- (`Stored the key (NN chars) as HUE_API_KEY and HUE_MCP_KEY`), never values. When git does not ignore the env file, the command
175
- warns; `--gitignore` appends the file name to the `.gitignore` next to it. Exit codes: `0` stored,
176
- `1` failed, `2` usage error, `130` interrupted.
171
+ The production MCP server is available only at `https://mcp.hue.run/mcp`. `hue login` uses
172
+ `https://mcp.staging.hue.run/mcp` with `https://staging.hue.run` and `<origin>/api/mcp` for other
173
+ development or preview origins; plain HTTP origins are accepted for loopback test servers only.
174
+ Output names variables and lengths (`Stored the key (NN chars) as HUE_API_KEY and HUE_MCP_KEY`),
175
+ never values. When git does not ignore the env file, the command warns; `--gitignore` appends the
176
+ file name to the `.gitignore` next to it. Exit codes: `0` stored, `1` failed, `2` usage error, `130`
177
+ interrupted.
177
178
 
178
179
  ## Install the MCP for your coding agent
179
180
 
@@ -193,16 +194,26 @@ for staging). Hue's Settings page does not show these snippets; this command kee
193
194
  for `claude-code`, `codex` and `conductor`. Hue does not yet accept Cursor's sign-in callback, so
194
195
  `cursor` and the other clients use a key.
195
196
 
197
+ `--project <id-or-slug>` pins either kind of connection to one project. The command adds or
198
+ replaces `?project=<value>` while keeping `toolsets`, and names the server `hue-<value>` so it can
199
+ sit beside the organization-wide `hue` connection. A pinned connection's tools do not publish or
200
+ accept `project_id`; `list_projects` returns only that project with `connection.pinned: true`.
201
+ The printed sign-in Codex TOML keeps its URL bare and sends `X-Hue-MCP-Project` together with any
202
+ `X-Hue-MCP-Toolsets` header. An empty pin is refused. If the selected project is inactive or the
203
+ credential cannot reach it, Hue returns HTTP `404`; change or remove `--project`, because signing
204
+ in again does not change the credential's reach.
205
+
196
206
  `--read-only` adds `?read_only=true` to a key configuration's URL, so Hue hides and rejects write
197
207
  tools whatever the key allows. A project key, with or without it, keeps reaching its one project.
198
208
  With `--auth oauth` it is refused: a sign-in connection has **Read and write** access, so use a
199
209
  **Read** project key for read-only access.
200
210
 
201
- `--toolsets <names>` chooses the tools the connection lists: `all`, `observe` (the production
202
- reads: projects, traces, spans and their recorded content, sessions, trace checks, intents and
203
- documentation), or the groups `project`, `traces`, `evals`, `environments`, `intents` and `docs`,
204
- comma-separated. The selection is added to the URL as `?toolsets=<names>`, for a key or a sign-in
205
- configuration alike. Without `--toolsets`, `claude-code`, `codex` and `conductor` select `all`,
211
+ `--toolsets <names>` chooses the tools the connection lists: `all`; the profiles `observe`
212
+ (production reads), `author` (author evaluations) and `evaluate` (run an agent against a case);
213
+ or the groups `project`, `traces`, `eval_sets`, `runs`, `judges`, `cases`, `runners`,
214
+ `environments`, `intents` and `docs`, comma-separated. Each group holds at most 20 tools. The
215
+ selection is added to the URL as `?toolsets=<names>`, for a key or a sign-in configuration alike.
216
+ Without `--toolsets`, `claude-code`, `codex` and `conductor` select `all`,
206
217
  because those agents defer MCP tools behind their own tool search; `cursor`, which caps the tools
207
218
  it loads, selects `observe`; the other clients list Hue's default. The default is the production
208
219
  reads plus `search_hue_tools`, which finds every other tool the connection can call, and
@@ -212,6 +223,14 @@ with write access), so nothing is out of reach. `--toolsets` replaces a selectio
212
223
  passed on, because Hue ignores them and would list its default. `get_project_context` is always
213
224
  listed and reports the selection.
214
225
 
226
+ MCP arguments and results name ids after their product objects. Eval tools use `eval_set_id`,
227
+ `eval_set_version_id`, `from_eval_set_version_id` and `eval_set_case_id`; `evaluator_id`,
228
+ `evaluator_version_id` and `evaluator_version_ids`; `run_id` and `baseline_run_id`; and
229
+ `scoring_run_id` and `scoring_item_id`. Dispatches use `managed_run_id` and `local_run_id`,
230
+ reviewed cases `case_id`, environment versions `environment_version_id`, and trace-check versions
231
+ `trace_check_version_id` or `active_trace_check_version_id`. List results are named `eval_sets`,
232
+ `evaluators`, `runs`, `scoring_runs` and `cases`, while a row's own id remains `id`.
233
+
215
234
  | Client | Key (`--auth key`) | Sign-in (`--auth oauth`) |
216
235
  | --- | --- | --- |
217
236
  | `claude-code` | Merges `mcpServers.hue` into `./.mcp.json`. `--scope user` runs `claude mcp add --transport http --scope user hue URL --header 'Authorization: Bearer ${HUE_MCP_KEY}'` when `claude` is on `PATH`, otherwise prints it. | Merges `mcpServers.hue` with only `type` and `url` into `./.mcp.json`; `--scope user` runs `claude mcp add --transport http --scope user hue URL`. Then run `/mcp` in Claude Code, select `hue` and choose **Authenticate**. |
@@ -234,19 +253,41 @@ through a trace read) end to end; if Hue's tools do not load after signing in, r
234
253
  again with `--auth key`.
235
254
 
236
255
  JSON files are parsed and merged: other servers, inputs and top-level fields are kept, only the
237
- `hue` entry is replaced, and invalid JSON (including comments) is refused together with the snippet
238
- to add by hand. Files are written with mode `0644` through a temporary file and an atomic rename;
239
- symlinks are refused. `--dry-run` prints the resulting file content or commands without writing or
240
- running; `--print` prints only the snippet. Client CLIs run without a shell, so the
241
- `${HUE_MCP_KEY}` reference reaches them literally; the printed commands use single quotes for the
242
- same reason, and quote a URL with a query such as `?read_only=true`, whose `?` is a zsh glob.
256
+ selected server entry is replaced (`hue`, or `hue-<value>` with `--project`), and invalid JSON
257
+ (including comments) is refused together with the snippet
258
+ to add by hand. Files are written through a temporary file and an atomic rename. A new file is
259
+ owner-only (mode `0600`, narrowed further by the umask), because other servers' literal tokens often
260
+ end up in it; a replaced file keeps its read, write and execute permissions, so a file kept at
261
+ `0600` is never widened, except that other accounts lose write access (they could add a command for
262
+ the client to run). It keeps its group, and its owner when root runs the command (if root cannot
263
+ give the file back, the write fails and the old file stays); when the group cannot be kept, the
264
+ group and other accounts get only the access both had. Setuid, setgid and sticky bits are dropped,
265
+ and an access control list is not kept: the replacement has only the mode bits, whose group bits are
266
+ the list's mask, plus any default list the directory gives new files, whose entries then get up to
267
+ the group's access. Restore a list afterwards if you use one. A symlinked file, or a symlinked
268
+ `.cursor` or `.vscode` directory, is refused; the current directory itself may be reached through a
269
+ symlink. The directory is checked before the file is read and again before the rename, and a
270
+ different file found in place of the one read is refused, but not atomically: an account that can
271
+ write the project directory and races the command can still swap in a link between the last check
272
+ and the rename. `--dry-run` prints the resulting file content or commands without writing or
273
+ running. In printed file content, every header and `env` value, and each other credential it
274
+ recognizes (a known token prefix, the value of an option such as `--api-key VALUE`, `-H VALUE`,
275
+ `-e NAME=VALUE` or `--token=VALUE`, a field or assignment such as `clientSecret`, `key` or
276
+ `STRIPE_KEY=`, a token-like URL path segment, a URL's query values, which read `%5Bredacted%5D`), is
277
+ shown as `[redacted]` unless the whole value is a variable or input reference such as
278
+ `Bearer ${HUE_MCP_KEY}`; the file keeps the values. Hue's own URL shows its `toolsets`, `project`
279
+ and `read_only` selections, with every other query value and any token-like path segment as
280
+ `[redacted]`, there and in the lines that report what the command did; a command printed for you to
281
+ run keeps `--url` as given. `--print` prints only the snippet. Client CLIs run without a shell, so
282
+ the `${HUE_MCP_KEY}` reference reaches them literally; the printed commands use single quotes for
283
+ the same reason, and quote a URL with a query such as `?read_only=true`, whose `?` is a zsh glob.
243
284
 
244
285
  After a key installation the command reminds you to export `HUE_MCP_KEY` in the shell that starts
245
286
  the client (VS Code prompts for the key instead). A desktop app started from the Dock or a launcher
246
287
  does not see that shell's variables, and Conductor's agents read the login-shell environment that
247
288
  Conductor captures, so sign-in is the simpler choice there. After a sign-in installation it names
248
289
  the client's authentication action. Both end with the prompt that verifies the connection:
249
- `Use the Hue MCP: call list_projects and confirm which project to inspect. Then call get_project_context and show that project's traces from the last 24 hours that need attention or have errors, with links; if there are none, show its 5 most recent traces. For an organization connection, pass the project's id as project_id on each call after list_projects.` `list_projects` returns a project key's one project too. Exit codes:
290
+ `Use the Hue MCP: call list_projects and confirm which project to inspect. Then call get_project_context and show that project's traces from the last 24 hours that need attention or have errors, with links; if there are none, show its 5 most recent traces. For an organization connection, pass the project's id or slug as project_id on each call after list_projects.` A pinned installation instead verifies `connection.pinned: true` and omits `project_id`. `list_projects` returns a project key's one project too. Exit codes:
250
291
  `0` done or printed, `1` failed, `2` usage error.
251
292
 
252
293
  ## Deliver simulated events to a local bot
@@ -255,12 +296,10 @@ the client's authentication action. Both end with the prompt that verifies the c
255
296
  agent such as a Slack bot can be tested without a public URL, the way `stripe listen --forward-to`
256
297
  delivers webhooks. Hue signs each request as the provider does (Slack Events API requests today);
257
298
  the command pulls them, forwards each one unchanged to `--forward-to` and reports the bot's answer
258
- to Hue, which settles or retries the event on the provider's schedule. It needs a **listen** event
259
- subscription on the world or on a connection key; the subscription's id and signing secret are shown
260
- once when it is created. Configure the bot with that signing secret where `SLACK_SIGNING_SECRET`
261
- went, then start the command: Hue offers the URL verification first, and events follow once the bot
262
- has answered it. Event subscriptions are not yet available on `https://app.hue.run`; until the
263
- origin offers them, the command stops with Hue's refusal.
299
+ to Hue. It needs a **listen** event subscription on the world or on a connection key, and the bot
300
+ configured with that subscription's signing secret where `SLACK_SIGNING_SECRET` went. Event
301
+ subscriptions are not yet available on `https://app.hue.run`; until the origin offers them, the
302
+ command exits `1` with the refusal it receives.
264
303
 
265
304
  ```sh
266
305
  hue listen --subscription <id> --forward-to http://localhost:3000/slack/events --env-path .env.world
@@ -269,8 +308,8 @@ The credential is the subscription's own, read from the environment (or `--env-p
269
308
  a variable already set keeps its value): `HUE_WORLD_TOKEN` for a world's subscription or
270
309
  `HUE_CONNECTION_KEY` for a connection key's, chosen with `--credential world-token|connection-key`
271
310
  when both are set. It is never taken from the command line, where other local processes and shell
272
- history can read it. A project key is refused: a value equal to `HUE_API_KEY` or `HUE_MCP_KEY` exits
273
- 2 before any request, and Hue answers any credential that is not the subscription's with `401`.
311
+ history can read it. A project key is refused: a value equal to `HUE_API_KEY`, `HUE_MCP_KEY`,
312
+ `HUE_PROJECT_KEY` or `HUE_SERVICE_KEY` exits 2 before any request.
274
313
  `--origin` (default `HUE_BASE_URL`, then `https://app.hue.run`) must be HTTPS, or plain HTTP on a
275
314
  loopback test server.
276
315
 
@@ -282,12 +321,14 @@ a later one is a timeout. A failing answer with `x-slack-no-retry: 1` is reporte
282
321
  retrying it. Only a successful URL verification answer's body (at most 4 KiB, for its challenge) is
283
322
  sent to Hue; any other answer body stays on this machine. Each pull waits for at most 20 seconds and
284
323
  leases up to `--max` deliveries (1 to 10, default 10), which are forwarded concurrently; the next
285
- pull starts once they are acknowledged, and a delivery beyond `--max` is never forwarded. A provider
286
- retry of an event is a new delivery and is forwarded again with its `X-Slack-Retry-Num` and
324
+ pull starts once they are acknowledged, and a delivery beyond `--max` is never forwarded. A waiting
325
+ pull that forwarded something is not repeated until 50 ms after it began, and one that answers at
326
+ once with nothing new to forward is not repeated for a second. A provider retry of
327
+ an event is a new delivery and is forwarded again with its `X-Slack-Retry-Num` and
287
328
  `X-Slack-Retry-Reason`, so the bot deduplicates by `event_id` as it does with Slack. A delivery Hue
288
329
  hands out a second time is not sent again; its recorded answer is repeated. An acknowledgement Hue
289
- did not answer is repeated until the delivery's lease ends, as Hue stated it (30 seconds from the
290
- lease).
330
+ did not answer is repeated until the delivery's lease ends, as the pull's answer stated it, and for
331
+ at most 30 seconds.
291
332
 
292
333
  `--forward-to` must name this machine: `localhost`, a `.localhost` name or a loopback address, and a
293
334
  name is refused unless every address it resolves to is a loopback address. `--allow-remote-forward`
@@ -298,15 +339,16 @@ bot's status and duration, and the state Hue recorded), never a body, a signatur
298
339
  credentials in error messages are replaced with `[redacted]`.
299
340
 
300
341
  Ctrl+C or `SIGTERM` stops pulling (a waiting pull is cancelled), finishes the deliveries in flight
301
- and acknowledges them, then exits `0`; a second Ctrl+C abandons the forwards and acknowledgements
302
- still in flight and exits `130`. Nothing is acknowledged before the bot answered or its window
303
- closed, and a lease left unacknowledged lapses into a timeout that Hue retries on the provider's
304
- schedule, so no event is lost and none is marked delivered unanswered. Network errors, `429`, `5xx`
342
+ and acknowledges them, then exits `0`, no sooner than a second after the first Ctrl+C. A second
343
+ Ctrl+C a second or more after the first abandons the forwards and acknowledgements still in flight
344
+ and exits `130`; one within a second of the first, such as the copy `npx` forwards to the command,
345
+ is the same stop. Nothing is acknowledged before the bot answered or its window closed; a delivery
346
+ left unacknowledged is left to its lease. Network errors, `429`, `5xx`, an unreadable pull answer
305
347
  and another open pull for the same subscription (a second `hue listen`) are retried with backoff up
306
348
  to 30 seconds; a `Retry-After` can lengthen a wait to at most 60 seconds, never shorten it. Exit
307
349
  codes: `0` stopped, `1` Hue refused the credential, the subscription (unknown, revoked, or one that
308
- delivers to a request URL) or the pull itself (a redirect, another refusal or an unreadable answer),
309
- `2` usage error, `130` interrupted twice.
350
+ delivers to a request URL) or the pull itself (a redirect, another refusal or an answer without
351
+ deliveries), `2` usage error, `130` interrupted twice.
310
352
 
311
353
  ## Local state and conflicts
312
354
 
@@ -412,7 +454,7 @@ node packages/sdk-typescript/scripts/verify-package.mjs --artifacts-dir .artifac
412
454
  # Set project to an existing supported fixture; use the same directory on resume.
413
455
  project=/absolute/path/to/supported-fixture
414
456
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
415
- --archive .artifacts/typescript/hue-run-sdk-0.11.1.tgz \
457
+ --archive .artifacts/typescript/hue-run-sdk-0.11.3.tgz \
416
458
  --origin https://STAGING_ORIGIN \
417
459
  --project "$project" --command setup \
418
460
  --evidence .context/setup-staging-before-claim.json
@@ -423,7 +465,7 @@ the private local handoff and finish the real browser claim, then reconcile the
423
465
 
424
466
  ```sh
425
467
  node packages/sdk-typescript/scripts/verify-setup-live.mjs \
426
- --archive .artifacts/typescript/hue-run-sdk-0.11.1.tgz \
468
+ --archive .artifacts/typescript/hue-run-sdk-0.11.3.tgz \
427
469
  --origin https://STAGING_ORIGIN \
428
470
  --project "$project" --command claim \
429
471
  --evidence .context/setup-staging-after-claim.json
package/README.md CHANGED
@@ -165,8 +165,10 @@ call carries `error.type` (`mcp_error`, the provider's status or error code) and
165
165
  text, credentials scrubbed (URL userinfo, query values and fragments, or the whole URL for a scheme
166
166
  other than `http(s)`, `ws(s)` and `ftp`; tokens with a known credential prefix such as `hue_sk_`,
167
167
  `sk-` or `xoxb-`; authorization-scheme credentials and an `Authorization` header's whole value;
168
- the value of a credential-named `key=value` or `key: value` pair, quoted or not) and cut to 1,024
169
- characters. An `mcp_list_tools`
168
+ the value of a credential-named `key=value`, `key: value` or `key => value` pair, quoted, bare or
169
+ a whole `[…]` or `{…}` (to the end of the text when it does not close), a quote that does not close
170
+ on its line running to its end; a JSON or `%` escape such as `\n` or `%20` ends a word) and cut to
171
+ 1,024 characters and a `…`. An `mcp_list_tools`
170
172
  item becomes a `tools/list` child span with that server's `gen_ai.tool.definitions`; with
171
173
  `captureContent: false` it carries only `hue.tool.names` and `hue.tool.definitions.sha256`, the
172
174
  summary described below. Pass the request so each server's host is recorded as `server.address`
@@ -27,6 +27,10 @@ export interface Output {
27
27
  /** Diagnostics; always stderr. */
28
28
  error(line: string): void;
29
29
  }
30
+ /** Agent key from the adapter filename, or the first script-like token of a command. An
31
+ * interpreter is skipped with a Windows executable suffix too (`node.exe`, and `bun.exe`, as npm
32
+ * installs Bun on every platform). */
33
+ export declare function derivedAgentKey(adapterFile: string | undefined, command: string | undefined): string;
30
34
  /**
31
35
  * Spawns the agent command once in its own process group and returns its trimmed stdout. A
32
36
  * timeout or Ctrl+C stops the agent the shell started, not only the shell: a survivor would still
package/dist/cli/eval.js CHANGED
@@ -11,7 +11,7 @@ import { createEnvironmentClient } from "../environment/client.js";
11
11
  import { agentEnvironment, isHueControlPlaneCredential, stripHueControlPlaneCredentials, writeMcpConfig, } from "../environment/world.js";
12
12
  import { EvaluationClient, HueApiError } from "../evals/client.js";
13
13
  import { TargetResult } from "../evals/types.js";
14
- import { CheckpointIdentityError, CheckpointStore } from "../evals/checkpoint.js";
14
+ import { CheckpointIdentityError, CheckpointStore, checkpointPath } from "../evals/checkpoint.js";
15
15
  import { onForcedExit, runForcedExitCleanups } from "../evals/exit-cleanup.js";
16
16
  import { safeFilename } from "../evals/files.js";
17
17
  import { digest } from "../evals/json.js";
@@ -179,15 +179,16 @@ const INTERPRETERS = new Set([
179
179
  "zsh",
180
180
  "env",
181
181
  ]);
182
- /** Agent key from the adapter filename, or the first script-like token of a command. */
183
- function derivedAgentKey(adapterFile, command) {
182
+ /** Agent key from the adapter filename, or the first script-like token of a command. An
183
+ * interpreter is skipped with a Windows executable suffix too (`node.exe`, and `bun.exe`, as npm
184
+ * installs Bun on every platform). */
185
+ export function derivedAgentKey(adapterFile, command) {
184
186
  if (adapterFile)
185
187
  return slug(basename(adapterFile, extname(adapterFile)));
186
188
  const tokens = (command ?? "").trim().split(/\s+/);
187
189
  const names = tokens.map((token) => token.split(/[\\/]/).pop() ?? "");
188
- const script = names.find((name) => name && !name.startsWith("-") && !INTERPRETERS.has(name.toLowerCase())) ??
189
- names[0] ??
190
- "";
190
+ const interpreter = (name) => INTERPRETERS.has(name.toLowerCase().replace(/\.(?:exe|cmd|bat)$/, ""));
191
+ const script = names.find((name) => name && !name.startsWith("-") && !interpreter(name)) ?? names[0] ?? "";
191
192
  return slug(basename(script, extname(script)));
192
193
  }
193
194
  function defaultRunName(agent) {
@@ -809,9 +810,13 @@ export function explain(error) {
809
810
  }
810
811
  /** `.hue/eval/<agent-key>/<project>/<leaf>` unless overridden; the SDK binds each store to one
811
812
  * project and origin, so switching keys or deployments must not collide. */
812
- async function prepareCheckpointDirectory(explicit, agentKey, projectId, leaf) {
813
+ async function prepareCheckpointDirectory(explicit, agentKey, projectId, ...leaf) {
814
+ const segments = [
815
+ [projectId, "project id"],
816
+ ...leaf.map((part) => [part, "checkpoint kind"]),
817
+ ];
813
818
  if (explicit)
814
- return join(resolve(explicit), projectId, leaf);
819
+ return checkpointPath(explicit, ...segments);
815
820
  const root = resolve(".hue", "eval");
816
821
  await mkdir(root, { recursive: true, mode: 0o700 });
817
822
  const ignore = join(root, ".gitignore");
@@ -821,7 +826,7 @@ async function prepareCheckpointDirectory(explicit, agentKey, projectId, leaf) {
821
826
  catch {
822
827
  await writeFile(ignore, "*\n", { flag: "wx", mode: 0o600 }).catch(() => undefined);
823
828
  }
824
- return join(root, agentKey, projectId, leaf);
829
+ return checkpointPath(join(root, agentKey), ...segments);
825
830
  }
826
831
  /** Worker-side client that reports registration and claims without changing the worker. */
827
832
  class ObservedClient extends EvaluationClient {
@@ -1209,7 +1214,7 @@ async function runDirect(run, connection) {
1209
1214
  client,
1210
1215
  hue: run.hue,
1211
1216
  experimentId,
1212
- checkpointDirectory: join(store.directory, experimentId),
1217
+ checkpointDirectory: checkpointPath(store.directory, [experimentId, "experiment id"]),
1213
1218
  // Outputs, error messages and explanations are stored unless opted out; --content governs
1214
1219
  // only the telemetry.
1215
1220
  persistResultContent: !values["no-output"],
@@ -1267,7 +1272,7 @@ async function runWorker(values, connection, adapter, agent, hue, output, signal
1267
1272
  ? undefined
1268
1273
  : integer("max-runs", values["max-runs"], 1, 1, 1_000_000);
1269
1274
  const project = await client.checkConnection();
1270
- const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], agent.key, project.id, join("worker", slug(agent.revision) || "dev"));
1275
+ const checkpointDirectory = await prepareCheckpointDirectory(values["checkpoint-dir"], agent.key, project.id, "worker", slug(agent.revision) || "dev");
1271
1276
  let current;
1272
1277
  client.onRegistered = (registered) => {
1273
1278
  output.log(`Registered agent ${registered.key} (revision ${registered.revision}) with ${connection.baseUrl}; waiting for runs launched from Hue${maxRuns ? ` (stops after ${maxRuns})` : " (Ctrl+C to stop)"}`);
@@ -23,7 +23,7 @@ export interface ListenCommandIo {
23
23
  /** Delays between retries of a failed pull. */
24
24
  retryDelaysMs?: readonly number[];
25
25
  }
26
- export declare const LISTEN_USAGE = "Usage: hue listen --subscription ID --forward-to URL [--origin URL] [--env-path PATH]\n [--credential world-token|connection-key] [--max N] [--allow-remote-forward]\n\nDeliver a simulated world's events to a receiver on this machine. Hue's signed requests (Slack\nEvents API requests today) are pulled with the subscription's own credential, forwarded unchanged\nto --forward-to, and acknowledged with the receiver's answer. Press Ctrl+C to stop: pulls stop,\ndeliveries in flight are finished and acknowledged, and nothing unanswered is acknowledged.\n\nThe credential is read from the environment, never from the command line:\n HUE_WORLD_TOKEN the world token, for a subscription on one world\n HUE_CONNECTION_KEY the connection key, for a subscription on a connection key\nA project key (HUE_API_KEY, HUE_MCP_KEY) is refused.\n\nOptions:\n --subscription ID The listen subscription to deliver (its id from Hue)\n --forward-to URL The local receiver, for example http://localhost:3000/slack/events\n --origin URL Hue origin (default HUE_BASE_URL, then https://app.hue.run)\n --env-path PATH Load a dotenv file first (--env-file also works when it exists)\n --credential KIND world-token or connection-key, when both variables are set\n --max N Deliveries leased per pull and forwarded at once, 1 to 10 (default 10)\n --allow-remote-forward Allow a --forward-to host other than this machine, over https only\n -h, --help Show this help";
26
+ export declare const LISTEN_USAGE = "Usage: hue listen --subscription ID --forward-to URL [--origin URL] [--env-path PATH]\n [--credential world-token|connection-key] [--max N] [--allow-remote-forward]\n\nDeliver a simulated world's events to a receiver on this machine. Hue's signed requests (Slack\nEvents API requests today) are pulled with the subscription's own credential, forwarded unchanged\nto --forward-to, and acknowledged with the receiver's answer. Press Ctrl+C to stop: pulls stop,\ndeliveries in flight are finished and acknowledged, and nothing unanswered is acknowledged.\n\nThe credential is read from the environment, never from the command line:\n HUE_WORLD_TOKEN the world token, for a subscription on one world\n HUE_CONNECTION_KEY the connection key, for a subscription on a connection key\nA project key (HUE_API_KEY, HUE_MCP_KEY, HUE_PROJECT_KEY or HUE_SERVICE_KEY) is refused.\n\nOptions:\n --subscription ID The listen subscription to deliver (its id from Hue)\n --forward-to URL The local receiver, for example http://localhost:3000/slack/events\n --origin URL Hue origin (default HUE_BASE_URL, then https://app.hue.run)\n --env-path PATH Load a dotenv file first (--env-file also works when it exists)\n --credential KIND world-token or connection-key, when both variables are set\n --max N Deliveries leased per pull and forwarded at once, 1 to 10 (default 10)\n --allow-remote-forward Allow a --forward-to host other than this machine, over https only\n -h, --help Show this help";
27
27
  /** A leased delivery: the exact request the provider would send. */
28
28
  interface Delivery {
29
29
  deliveryId: string;
@@ -5,6 +5,7 @@ import { request as httpsRequest } from "node:https";
5
5
  import { isIP } from "node:net";
6
6
  import { resolve } from "node:path";
7
7
  import { parseArgs, parseEnv } from "node:util";
8
+ import { HUE_CONTROL_PLANE_VARIABLES } from "../environment/world.js";
8
9
  import { scrubCredentialText } from "../tool-definitions.js";
9
10
  import { sdkVersion } from "../version.js";
10
11
  import { envFileArgument, envFileOptions } from "./env-file.js";
@@ -32,6 +33,17 @@ const ACK_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000, 4_000];
32
33
  const MAX_RETRY_AFTER_MS = 60_000;
33
34
  /** Deliveries whose answer is remembered, so a delivery Hue hands out again is not re-sent. */
34
35
  const REMEMBERED_DELIVERIES = 1_024;
36
+ /** The least time between pulls that forwarded nothing new, so repeats cannot spin. */
37
+ const IDLE_PULL_FLOOR_MS = 1_000;
38
+ /** The least time between the starts of pulls that forwarded something, so a Hue that answers
39
+ * every pull at once with new deliveries is pulled at most 20 times a second. */
40
+ const BUSY_PULL_FLOOR_MS = 50;
41
+ /** The most of a text from elsewhere (an error message, a URL) that is scrubbed and printed, in
42
+ * code points, as of a provider's error text; a longer one is cut there and `…` marks the cut. */
43
+ const MAX_FOREIGN_TEXT = 16_384;
44
+ /** A stop request this soon after the first is the same one: under `npx`, npm forwards the
45
+ * terminal's SIGINT to a process group that already received it. */
46
+ const REPEATED_STOP_MS = 1_000;
35
47
  /** Connection-level headers the local request sets for itself. */
36
48
  const HOP_BY_HOP = new Set([
37
49
  "host",
@@ -64,7 +76,7 @@ deliveries in flight are finished and acknowledged, and nothing unanswered is ac
64
76
  The credential is read from the environment, never from the command line:
65
77
  HUE_WORLD_TOKEN the world token, for a subscription on one world
66
78
  HUE_CONNECTION_KEY the connection key, for a subscription on a connection key
67
- A project key (HUE_API_KEY, HUE_MCP_KEY) is refused.
79
+ A project key (HUE_API_KEY, HUE_MCP_KEY, HUE_PROJECT_KEY or HUE_SERVICE_KEY) is refused.
68
80
 
69
81
  Options:
70
82
  --subscription ID The listen subscription to deliver (its id from Hue)
@@ -413,7 +425,7 @@ function selectCredential(env, choice) {
413
425
  const credential = kind === "world-token" ? worldToken : connectionKey;
414
426
  if (!credential)
415
427
  throw new UsageError(`${variable} is not set`);
416
- for (const projectVariable of ["HUE_API_KEY", "HUE_MCP_KEY"])
428
+ for (const projectVariable of HUE_CONTROL_PLANE_VARIABLES)
417
429
  if (env[projectVariable]?.trim() === credential)
418
430
  throw new UsageError(`${variable} holds the project key in ${projectVariable}; hue listen pulls only with the subscription's world token or connection key`);
419
431
  if (kind === "world-token" && !WORLD_TOKEN.test(credential))
@@ -485,7 +497,16 @@ export async function runListenCommand(argv, io = {}) {
485
497
  out = out.replaceAll(secret, "[redacted]");
486
498
  return out.replace(/[^\P{Cc}\n\t]|\p{Cf}/gu, " ");
487
499
  };
488
- const foreign = (text) => scrubCredentialText(text);
500
+ const foreign = (text) => {
501
+ let kept = "";
502
+ let seen = 0;
503
+ for (const character of text) {
504
+ if (seen++ === MAX_FOREIGN_TEXT)
505
+ return `${scrubCredentialText(kept, true)}…`;
506
+ kept += character;
507
+ }
508
+ return scrubCredentialText(text);
509
+ };
489
510
  const out = (line) => void stdout.write(`${clean(line)}\n`);
490
511
  const warn = (line) => void stderr.write(`${clean(line)}\n`);
491
512
  let subscription;
@@ -547,16 +568,18 @@ export async function runListenCommand(argv, io = {}) {
547
568
  accept: "application/json",
548
569
  "user-agent": `hue-sdk-typescript/${sdkVersion} hue-listen`,
549
570
  };
550
- // The first stop request stops pulling and lets deliveries in flight finish; a second one
551
- // abandons their acknowledgements, whose leases then lapse into timeouts.
571
+ // The first stop request stops pulling and lets deliveries in flight finish; a second one, a
572
+ // second or more later, abandons their acknowledgements, whose leases then lapse into timeouts.
552
573
  const stopping = new AbortController();
553
574
  const forced = new AbortController();
575
+ let stoppedAt = 0;
554
576
  const removeInterrupt = (io.onInterrupt ?? defaultOnInterrupt)(() => {
555
577
  if (!stopping.signal.aborted) {
578
+ stoppedAt = performance.now();
556
579
  stopping.abort();
557
580
  warn("Stopping: finishing deliveries in flight (press Ctrl+C again to abandon them).");
558
581
  }
559
- else
582
+ else if (performance.now() - stoppedAt >= REPEATED_STOP_MS)
560
583
  forced.abort();
561
584
  });
562
585
  const remembered = new Map();
@@ -736,6 +759,7 @@ export async function runListenCommand(argv, io = {}) {
736
759
  }
737
760
  }
738
761
  };
762
+ /** Forwards and acknowledges one delivery; true when its request reached the receiver now. */
739
763
  const deliver = async (delivery, deadline) => {
740
764
  // Hue may hand out a delivery again (a pull whose answer was retried); its request already
741
765
  // reached the receiver, so the recorded answer is repeated and nothing is sent twice.
@@ -751,7 +775,7 @@ export async function runListenCommand(argv, io = {}) {
751
775
  : `${delivery.eventId ?? "event"}${delivery.retryNum ? ` retry ${delivery.retryNum}` : ""}`;
752
776
  if (answer === null) {
753
777
  out(`${new Date().toISOString()} ${name} -> abandoned; not acknowledged, the lease lapses into a timeout`);
754
- return;
778
+ return false;
755
779
  }
756
780
  if (!previous) {
757
781
  remembered.set(delivery.deliveryId, answer);
@@ -762,6 +786,7 @@ export async function runListenCommand(argv, io = {}) {
762
786
  if (result.kind === "revoked")
763
787
  fatal ??= `Subscription ${subscription} is revoked.`;
764
788
  out(`${new Date().toISOString()} ${name}${previous ? " (repeated)" : ""} -> ${describeAnswer(answer)}; ${describeAck(result)}`);
789
+ return !previous;
765
790
  };
766
791
  out(`Forwarding subscription ${subscription} from ${foreign(origin)} to ${foreign(target.href)} using the ${label}. Press Ctrl+C to stop.`);
767
792
  let failures = 0;
@@ -786,9 +811,7 @@ export async function runListenCommand(argv, io = {}) {
786
811
  continue;
787
812
  }
788
813
  failures = 0;
789
- // A pull that answers empty at once, though it could wait, is not repeated at once.
790
- if (ready && result.deliveries.length === 0 && performance.now() - pulledAt < 1_000)
791
- await sleep(1_000, stopping.signal);
814
+ const waited = ready;
792
815
  if (!ready) {
793
816
  ready = true;
794
817
  out("Ready: waiting for events.");
@@ -797,11 +820,30 @@ export async function runListenCommand(argv, io = {}) {
797
820
  warn(`Skipped ${result.skipped} delivery(ies) this version of @hue-run/sdk cannot forward; their leases lapse and Hue retries them.`);
798
821
  if (result.excess)
799
822
  warn(`Hue leased ${result.excess} more delivery(ies) than --max ${max}; they are not forwarded, their leases lapse and Hue retries them.`);
800
- await Promise.all(result.deliveries.map((delivery) => deliver(delivery, result.receivedAt + result.leaseLeftMs(delivery))));
823
+ const forwarded = await Promise.all(result.deliveries.map((delivery) => deliver(delivery, result.receivedAt + result.leaseLeftMs(delivery))));
824
+ // A pull that could wait but answered at once with nothing new to forward (none due, only
825
+ // deliveries already answered, or ones this client skips) is not repeated at once, so a
826
+ // Hue that hands them out again or refuses their acknowledgements is not pulled in a loop.
827
+ // One that forwarded something is repeated no sooner than BUSY_PULL_FLOOR_MS after it began.
828
+ const elapsed = performance.now() - pulledAt;
829
+ if (waited && !forwarded.includes(true) && elapsed < IDLE_PULL_FLOOR_MS)
830
+ await sleep(IDLE_PULL_FLOOR_MS, stopping.signal);
831
+ else if (waited && elapsed < BUSY_PULL_FLOOR_MS)
832
+ await sleep(BUSY_PULL_FLOOR_MS - elapsed, stopping.signal);
801
833
  }
802
834
  }
803
835
  finally {
804
- removeInterrupt();
836
+ // A repeated stop request can still arrive once the loop has ended (npm forwards Ctrl+C a few
837
+ // milliseconds late), and without a listener, or once the process has begun to exit, the
838
+ // runtime's default handler would end it by SIGINT. So the listener, and the process, stay
839
+ // until such a request would count as a new one: at most a second after the first.
840
+ const repeatable = stopping.signal.aborted
841
+ ? REPEATED_STOP_MS - (performance.now() - stoppedAt)
842
+ : 0;
843
+ if (repeatable > 0)
844
+ setTimeout(removeInterrupt, repeatable);
845
+ else
846
+ removeInterrupt();
805
847
  }
806
848
  if (fatal !== null) {
807
849
  warn(`Error: ${fatal}`);
package/dist/cli/mcp.d.ts CHANGED
@@ -20,15 +20,17 @@ export declare const DEFAULT_MCP_URL = "https://mcp.hue.run/mcp";
20
20
  * lists them, after which each call takes `project_id`. It reads what needs attention and falls
21
21
  * back to recent traces, so a project without errors still proves the read path.
22
22
  */
23
- export declare const MCP_VERIFY_PROMPT = "Use the Hue MCP: call list_projects and confirm which project to inspect. Then call get_project_context and show that project's traces from the last 24 hours that need attention or have errors, with links; if there are none, show its 5 most recent traces. For an organization connection, pass the project's id as project_id on each call after list_projects.";
24
- /** Toolset names Hue's MCP server accepts: `all`, its catalog groups and the `observe` profile. */
25
- export declare const MCP_TOOLSETS: readonly ["all", "observe", "project", "traces", "evals", "environments", "intents", "docs"];
23
+ export declare const MCP_VERIFY_PROMPT = "Use the Hue MCP: call list_projects and confirm which project to inspect. Then call get_project_context and show that project's traces from the last 24 hours that need attention or have errors, with links; if there are none, show its 5 most recent traces. For an organization connection, pass the project's id or slug as project_id on each call after list_projects.";
24
+ /** Toolset names Hue's MCP server accepts: `all`, its catalog groups and its curated profiles. */
25
+ export declare const MCP_TOOLSETS: readonly ["all", "observe", "author", "evaluate", "project", "traces", "eval_sets", "runs", "judges", "cases", "runners", "environments", "intents", "docs"];
26
26
  /**
27
27
  * The header form of `?toolsets=`. Only the sign-in Codex TOML uses it, as Hue's guide does, to
28
28
  * keep that URL bare; every other configuration carries the selection in its URL.
29
29
  */
30
30
  export declare const TOOLSETS_HEADER = "X-Hue-MCP-Toolsets";
31
- export declare const MCP_USAGE = "Usage: hue mcp install --client <claude-code|codex|conductor|cursor|vscode|windsurf|gemini>\n [--auth key|oauth] [--read-only] [--toolsets NAMES] [--url URL]\n [--scope project|user] [--dry-run] [--print]\n\nConfigure a coding agent to use the Hue MCP server. A key configuration references the\nHUE_MCP_KEY environment variable; a key value is never written. A sign-in configuration holds\nonly the URL: the client opens Hue in a browser, where you approve access to the projects of\none organization.\n\nOptions:\n --client NAME Coding agent to configure (required)\n --auth MODE key: reference HUE_MCP_KEY (the default, except for conductor)\n oauth: URL only, sign in with Hue in the client (claude-code, codex and\n conductor; the default for conductor)\n --read-only key only: add ?read_only=true to the URL so write tools are hidden. A\n sign-in connection has Read and write access; use a Read key for read-only\n --toolsets NAMES\n Tools to list, comma-separated: all, observe (production reads),\n project, traces, evals, environments, intents or docs, added to the\n URL as ?toolsets=. Defaults: all for claude-code, codex and conductor,\n which search their own tools; observe for cursor. Without a selection\n Hue lists the production reads and search_hue_tools, which reaches\n every other tool\n --url URL Hue MCP endpoint (default https://mcp.hue.run/mcp)\n --scope SCOPE claude-code only: project writes .mcp.json (default); user runs\n `claude mcp add --scope user`\n --dry-run Print the resulting file content or commands without writing or running\n --print Print the configuration snippet only\n -h, --help Show this help\n\nFiles: claude-code .mcp.json, cursor .cursor/mcp.json, vscode .vscode/mcp.json (relative to the\ncurrent directory). codex and gemini use their own CLI when it is on PATH; windsurf prints the\nsnippet for its user configuration file. conductor registers the server for its Claude Code\n(user scope) and Codex agents with their CLIs, printing the command for a CLI not on PATH.";
31
+ /** The header form of `?project=`, used by the sign-in Codex TOML to keep its URL bare. */
32
+ export declare const PROJECT_HEADER = "X-Hue-MCP-Project";
33
+ export declare const MCP_USAGE = "Usage: hue mcp install --client <claude-code|codex|conductor|cursor|vscode|windsurf|gemini>\n [--auth key|oauth] [--read-only] [--project ID-OR-SLUG]\n [--toolsets NAMES] [--url URL]\n [--scope project|user] [--dry-run] [--print]\n\nConfigure a coding agent to use the Hue MCP server. A key configuration references the\nHUE_MCP_KEY environment variable; a key value is never written. A sign-in configuration holds\nonly the URL: the client opens Hue in a browser, where you approve access to the projects of\none organization.\n\nOptions:\n --client NAME Coding agent to configure (required)\n --auth MODE key: reference HUE_MCP_KEY (the default, except for conductor)\n oauth: URL only, sign in with Hue in the client (claude-code, codex and\n conductor; the default for conductor)\n --read-only key only: add ?read_only=true to the URL so write tools are hidden. A\n sign-in connection has Read and write access; use a Read key for read-only\n --project VALUE Pin the connection to one project by id or slug. Its tools omit project_id,\n and the server is named hue-<value> so it can coexist with an unpinned hue\n --toolsets NAMES\n Tools to list, comma-separated: all; the observe, author or evaluate\n profiles; or project, traces, eval_sets, runs, judges, cases, runners,\n environments, intents or docs. Added to the URL as ?toolsets=.\n Defaults: all for claude-code, codex and conductor,\n which search their own tools; observe for cursor. Without a selection\n Hue lists the production reads and search_hue_tools, which reaches\n every other tool\n --url URL Hue MCP endpoint (default https://mcp.hue.run/mcp)\n --scope SCOPE claude-code only: project writes .mcp.json (default); user runs\n `claude mcp add --scope user`\n --dry-run Print the resulting file content or commands without writing or running\n --print Print the configuration snippet only\n -h, --help Show this help\n\nFiles: claude-code .mcp.json, cursor .cursor/mcp.json, vscode .vscode/mcp.json (relative to the\ncurrent directory). codex and gemini use their own CLI when it is on PATH; windsurf prints the\nsnippet for its user configuration file. conductor registers the server for its Claude Code\n(user scope) and Codex agents with their CLIs, printing the command for a CLI not on PATH.";
32
34
  /**
33
35
  * A URL as a POSIX shell word for printed commands: `?` is a zsh glob and `&` ends a command, so a
34
36
  * URL with a query (such as `?read_only=true`) is single-quoted. Executed commands pass argv.
@@ -38,7 +40,7 @@ export declare function shellWord(value: string): string;
38
40
  * Canonical Hue client snippets for a key configuration; the JSON values are also the merge
39
41
  * entries for config files.
40
42
  */
41
- export declare function renderMcpSnippets(url: string): {
43
+ export declare function renderMcpSnippets(url: string, serverName?: string): {
42
44
  claudeCodeServer: {
43
45
  type: string;
44
46
  url: string;
@@ -92,9 +94,9 @@ export declare function renderMcpSnippets(url: string): {
92
94
  /**
93
95
  * Sign-in snippets: the server URL and nothing secret. The client discovers Hue's OAuth metadata
94
96
  * from it and sends it, query included, on every request; the token's resource ignores the query,
95
- * so `?toolsets=` selects tools here as it does for a key.
97
+ * so `?toolsets=` and `?project=` select tools and a project here as they do for a key.
96
98
  */
97
- export declare function renderMcpSignInSnippets(url: string): {
99
+ export declare function renderMcpSignInSnippets(url: string, serverName?: string): {
98
100
  claudeCodeServer: {
99
101
  type: string;
100
102
  url: string;
@@ -124,6 +126,15 @@ export declare function parseToolsets(value: string): {
124
126
  * parameter is removed, so the connection lists Hue's default.
125
127
  */
126
128
  export declare function toolsetsMcpUrl(url: string, toolsets: string | undefined): string;
129
+ /** Parses the id or slug used to pin a connection and derives its coexisting server name. */
130
+ export declare function parseProject(value: string): {
131
+ project: string;
132
+ serverName: string;
133
+ } | {
134
+ error: string;
135
+ };
136
+ /** Replaces `?project=` while preserving the URL's other selections. */
137
+ export declare function projectMcpUrl(url: string, project: string | undefined): string;
127
138
  /** `?read_only=true` hides and rejects Hue's write tools for any key. */
128
139
  export declare function readOnlyMcpUrl(url: string): string;
129
140
  /** Validates the MCP endpoint: HTTPS, or HTTP for loopback test servers; no credentials or hash. */