@vecteur/cli 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,16 @@ With `VECTEUR_TOKEN`, runs require `--project ID` and `project use` requires a p
34
34
  Project PATs retain their server-defined scope; listing and creating Projects require an account
35
35
  login. `project list --json` preserves `{projects, active_projects, selected_project_id}`.
36
36
 
37
+ `run submit` continues the Project's conversation: it reads the Project's current head (the newest
38
+ Run's `state_hash`) and submits the next turn on it, so two submits in a row are two turns of one
39
+ study, exactly as in the web app. A new Project's first turn has no head. `--parent STATE_HASH`
40
+ skips the read and admits the turn only if that hash is still the head. The CLI never forks a
41
+ Project and never retries: a moved head exits `state_fork` naming the current head, and a Project
42
+ whose Run is still open exits `state_busy` naming that Run (and, when it waits on a question, the
43
+ web address where it can be answered). Nothing is admitted in either case. Once a turn is admitted,
44
+ any later failure names the Run: check it with `vecteur run get RUN_ID` instead of submitting
45
+ again, which would start another turn. Start a fresh study with `vecteur project create`.
46
+
37
47
  `run events` validates dense SSE sequence and returns the canonical direct record only when its
38
48
  embedded events match the stream. Redirects, secret-shaped responses, malformed responses and
39
49
  timeouts fail nonzero. `--json` sorts object keys for deterministic machine output.
package/bin/vecteur.js CHANGED
@@ -3,8 +3,8 @@ import { spawn } from "node:child_process";
3
3
  import { readFileSync } from "node:fs";
4
4
 
5
5
  import {
6
- CliError, VecteurClient, configuration, isTokenShaped, redact, requestTimeout, resolveOrigin,
7
- shouldOpenBrowser, waitForDeviceAuthorization,
6
+ CliError, VecteurClient, admittedFailure, configuration, isStateHash, isTokenShaped, redact,
7
+ requestTimeout, resolveOrigin, shouldOpenBrowser, waitForDeviceAuthorization,
8
8
  } from "../src/client.js";
9
9
  import {
10
10
  clearCredentials, credentialsPath, readCredentials, setCurrentOrigin, writeCredentials, selectProject,
@@ -43,8 +43,9 @@ function refuseOldNode() {
43
43
 
44
44
  function helpText(version) {
45
45
  return `vecteur ${version}
46
- login | logout | whoami | project list | project create <name> [--workspace <name-or-id>] | project use <name-or-id> | project get <id> | run submit [-p <id>] -a <ask> | run get <id> | run events <id> | artifact get <id> [--output <path>] [-p <id>] | projection get <run-id> <projection-id> [--output <path>] [-p <id>] | mcp
46
+ login | logout | whoami | project list | project create <name> [--workspace <name-or-id>] | project use <name-or-id> | project get <id> | run submit [-p <id>] -a <ask> [--parent <state-hash>] | run get <id> | run events <id> | artifact get <id> [--output <path>] [-p <id>] | projection get <run-id> <projection-id> [--output <path>] [-p <id>] | mcp
47
47
  --help --version --json --origin <url> --no-browser --local -p/--project -a/--ask --output <path>
48
+ run submit continues the Project's current head; --parent <state-hash> admits it only if that is still the head
48
49
  `;
49
50
  }
50
51
 
@@ -101,6 +102,10 @@ function parseCommand(argv) {
101
102
  const ask = takeOption("--ask", "-a");
102
103
  const workspace = takeOption("--workspace");
103
104
  const output = takeOption("--output");
105
+ const parent = takeOption("--parent");
106
+ if (parent !== undefined && (!isStateHash(parent) || args[0] !== "run" || args[1] !== "submit")) {
107
+ throw new CliError("usage_invalid", "Invalid command grammar");
108
+ }
104
109
  if (args.some((arg) => typeof arg === "string" && arg.startsWith("-"))) {
105
110
  throw new CliError("usage_invalid", "Invalid command grammar");
106
111
  }
@@ -142,7 +147,7 @@ function parseCommand(argv) {
142
147
  }
143
148
  if (args.length === 2 && args[0] === "run" && args[1] === "submit"
144
149
  && (projectId === undefined || value(projectId)) && value(ask) && output === undefined) {
145
- return { kind: "run_submit", projectId, ask, origin, json };
150
+ return { kind: "run_submit", projectId, ask, parent, origin, json };
146
151
  }
147
152
  if (args.length === 3 && args[0] === "run" && args[1] === "get" && value(args[2])
148
153
  && projectId === undefined && ask === undefined && output === undefined) {
@@ -336,19 +341,15 @@ async function main() {
336
341
  }
337
342
  else if (command.kind === "run_submit") {
338
343
  const projectId = await resolveRunProject(client, command.projectId);
339
- value = await client.runSubmit(projectId, command.ask, onEvent);
344
+ value = await client.runSubmit(projectId, command.ask, onEvent, undefined, command.parent);
340
345
  if (!command.json) {
341
- const currentTransferIds = await verifiedTransferIds(client, value);
342
- process.stdout.write(human(value, { projectId, currentTransferIds }));
343
346
  try {
347
+ const currentTransferIds = await verifiedTransferIds(client, value);
348
+ process.stdout.write(human(value, { projectId, currentTransferIds }));
344
349
  const cost = await client.customerCost(projectId);
345
350
  process.stdout.write(`\n${humanCost(cost)}`);
346
351
  } catch (error) {
347
- if (error instanceof CliError && error.code === "pat_expired") {
348
- throw new CliError("pat_expired",
349
- `credential expired after Run ${value.run_id} was admitted; next: vecteur login; then: vecteur run get ${value.run_id}`);
350
- }
351
- throw error;
352
+ throw admittedFailure(error, value.run_id);
352
353
  }
353
354
  return;
354
355
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vecteur/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vecteur": "bin/vecteur.js"
package/src/client.js CHANGED
@@ -23,8 +23,28 @@ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
23
23
  const MAX_FRAME_BYTES = 1024 * 1024;
24
24
  const MAX_EVENTS = 10_000;
25
25
  const CAS_REF_SHAPE = /^sha256:([0-9a-f]{64})$/;
26
+ const STATE_HASH_SHAPE = /^[0-9a-f]{64}$/;
26
27
 
27
28
 
29
+ /** The one shape of a design head, so `--parent` and the head read cannot disagree. */
30
+ export function isStateHash(value) {
31
+ return typeof value === "string" && STATE_HASH_SHAPE.test(value);
32
+ }
33
+
34
+ /**
35
+ * A failure after the acknowledgement names the admitted Run, which may still be running:
36
+ * retrying the submit would now continue its head and admit a second, billable turn.
37
+ */
38
+ export function admittedFailure(error, runId) {
39
+ if (!(error instanceof CliError)) return error;
40
+ if (error.code === "pat_expired") {
41
+ return new CliError("pat_expired",
42
+ `credential expired after Run ${runId} was admitted; next: vecteur login; then: vecteur run get ${runId}`);
43
+ }
44
+ return new CliError(error.code,
45
+ `Run ${runId} was admitted and may still be running; next: vecteur run get ${runId}`);
46
+ }
47
+
28
48
  export function redact(value) {
29
49
  return String(value).replace(TOKEN_PATTERN, "[REDACTED]");
30
50
  }
@@ -585,24 +605,83 @@ export class VecteurClient {
585
605
  }
586
606
  }
587
607
 
588
- async runSubmit(projectId, ask, onEvent = null, signal = undefined) {
589
- const value = await this.#json("POST", `/api/projects/${encodeURIComponent(projectId)}/runs`, {
590
- body: { ask, attachments: [], parent_state_hash: null, ui_context: null },
591
- idempotencyKey: randomUUID(),
592
- signal,
593
- });
608
+ /**
609
+ * The Project's newest Run from the owner page — the record the web chains on
610
+ * (`CanvasPage.vue` designHead). Its public `state_hash` is the design head once the Run is
611
+ * terminal. An empty page with a cursor is a newest Run this login may not see, not an empty
612
+ * Project, so it refuses rather than let the caller guess null.
613
+ */
614
+ async projectNewestRun(projectId, signal = undefined) {
615
+ const page = await this.#json("GET",
616
+ `/api/projects/${encodeURIComponent(projectId)}/runs?limit=1`, { signal });
617
+ const newest = page?.runs?.[0];
618
+ if (!exactKeys(page, ["runs", "next_before"]) || !Array.isArray(page.runs) || page.runs.length > 1
619
+ || (page.next_before !== null && typeof page.next_before !== "string")
620
+ || (newest !== undefined && (newest?.request?.project_id !== projectId
621
+ || !validateAdmitted(newest.response) || !isStateHash(newest.response.state_hash)))) {
622
+ throw new CliError("project_runs_invalid", "Server returned malformed Project run history");
623
+ }
624
+ if (!newest && page.next_before !== null) {
625
+ throw new CliError("project_head_hidden",
626
+ `the newest Run in Project ${projectId} is not visible to this login; nothing was submitted; `
627
+ + "next: continue in the web app, or pass --parent <state-hash>");
628
+ }
629
+ return newest?.response ?? null;
630
+ }
631
+
632
+ /**
633
+ * One turn, chained onto the Project's design head (OPE admits null only on a Project with no
634
+ * head, otherwise only the exact head). `parentStateHash` pins a compare-and-swap instead of
635
+ * reading the head. A 409 is re-read ONCE to name the head or the open Run, and never
636
+ * re-posted: the Idempotency-Key is per invocation, so a second POST is a second billable
637
+ * admission, written against a head the ask never saw.
638
+ */
639
+ async runSubmit(projectId, ask, onEvent = null, signal = undefined, parentStateHash = undefined) {
640
+ const parent = parentStateHash === undefined
641
+ ? (await this.projectNewestRun(projectId, signal))?.state_hash ?? null
642
+ : parentStateHash;
643
+ let value;
644
+ try {
645
+ value = await this.#json("POST", `/api/projects/${encodeURIComponent(projectId)}/runs`, {
646
+ body: { ask, attachments: [], parent_state_hash: parent, ui_context: null },
647
+ idempotencyKey: randomUUID(),
648
+ signal,
649
+ });
650
+ } catch (error) {
651
+ if (error instanceof CliError && (error.code === "state_fork" || error.code === "state_busy")) {
652
+ throw await this.#admissionRefused(error.code, projectId, parent, signal);
653
+ }
654
+ throw error;
655
+ }
594
656
  if (!validateRunAck(value)) {
595
657
  throw new CliError("run_ack_invalid", "Server returned malformed run acknowledgement");
596
658
  }
597
659
  try {
598
660
  return await this.runEvents(value.run_id, onEvent, signal);
599
661
  } catch (error) {
600
- if (error instanceof CliError && error.code === "pat_expired") {
601
- throw new CliError("pat_expired",
602
- `credential expired after Run ${value.run_id} was admitted; next: vecteur login; then: vecteur run get ${value.run_id}`);
603
- }
604
- throw error;
662
+ throw admittedFailure(error, value.run_id);
663
+ }
664
+ }
665
+
666
+ async #admissionRefused(code, projectId, sent, signal) {
667
+ let newest;
668
+ try { newest = await this.projectNewestRun(projectId, signal); } catch { newest = undefined; }
669
+ if (code === "state_busy" && newest?.terminal === "") {
670
+ const run = newest.run_id;
671
+ const question = newest.events.filter((event) => event.kind === "clarification").at(-1);
672
+ const waiting = question?.payload?.state === "requested"
673
+ ? ` [waiting for an answer: ${this.config.origin}/dashboard/${encodeURIComponent(projectId)}?run=${encodeURIComponent(run)}]`
674
+ : "";
675
+ return new CliError(code, `Run ${run} is still open in Project ${projectId}${waiting}; `
676
+ + `nothing was admitted; next: vecteur run get ${run}`);
677
+ }
678
+ if (code === "state_fork" && newest !== undefined) {
679
+ const moved = newest?.state_hash ?? null;
680
+ return new CliError(code, `Project ${projectId} head is ${moved}, this submit sent ${sent}; `
681
+ + "nothing was admitted; next: " + (moved === null ? "submit again without --parent"
682
+ : `submit again to continue ${moved}, or pass --parent ${moved}`));
605
683
  }
684
+ return new CliError(code, `nothing was admitted to Project ${projectId}; next: submit again`);
606
685
  }
607
686
 
608
687
  async runGet(runId, signal = undefined) {
package/src/contract.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // GENERATED by scripts/gen-contract.mjs from owner TypeScript projections.
2
- export const OWNER_CONTRACT_IDENTITY = "api-sha256:8f81ea544eecd9e81da3fd56ad816ac24991cb0e68f3b1cb45180711eaf16056;run-wire-sha256:eee3443d89c0f32e3949fa8832dde769a37d4d92f99b0016777ef0c44eda5d5c";
2
+ export const OWNER_CONTRACT_IDENTITY = "api-sha256:8f81ea544eecd9e81da3fd56ad816ac24991cb0e68f3b1cb45180711eaf16056;run-wire-sha256:03db13f2f5305ded2432fdd33c0db2f1459938584dce250bced24c218eda63ea";
3
3
  export const RECOVERY_ACTIONS = Object.freeze({"delete_project":{"label":"Delete a project","href":"/projects"},"upgrade_plan":{"label":"Upgrade your plan","href":"/dashboard/billing"},"wait_for_reset":{"label":"Wait until the UTC reset","href":null}});
4
4
  export const EVENT_KINDS = Object.freeze(["source","step_started","step_finished","tool_call","tool_result","synthesis","artifact","terminal","heartbeat","assistant_delta","knowledge_binding","engineering_projection","provider_attempt","clarification","clarification_evidence","input_rows"]);
5
5
  export const RETIRED_EVENT_KINDS = Object.freeze(["context_frame","cta","step_label"]);
package/src/mcp.js CHANGED
@@ -102,7 +102,8 @@ export function createVecteurMcpServer(client = new VecteurClient(), shutdownSig
102
102
  tool(server, "vecteur_project_get", "Get one PAT-bound Vecteur project.", projectInput, projectOutput,
103
103
  ({ project_id: projectId }, context) => client.projectGet(projectId, signal(context)));
104
104
 
105
- tool(server, "vecteur_run_submit", "Submit one project turn and return its terminal record.",
105
+ tool(server, "vecteur_run_submit",
106
+ "Submit one project turn that continues the Project's current head and return its terminal record.",
106
107
  submitInput, admittedOutput, async ({ project_id: projectId, ask }, context) => {
107
108
  const progressToken = context.mcpReq._meta?.progressToken;
108
109
  const onEvent = progressToken === undefined ? null : (event) => context.mcpReq.notify({