@tonbo/cli 0.3.4 → 0.4.0

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
@@ -78,12 +78,29 @@ tonbo run "Reply with exactly READY."
78
78
 
79
79
  Choose `Use an existing agent` during `init` when the Agent already exists. `tonbo agent create <name>` and `tonbo agent use <name-hostname-or-id>` remain explicit automation and recovery commands.
80
80
 
81
+ Every Agent has a home region, chosen when it is created and never changed afterwards. Its Deployments, Sessions and files stay in that region. The hostname and the SSH destination are the same in every region. Name the region with `--region`; `tonbo region list` shows the regions that accept new resources (`allocation_enabled`). Without `--region`, Tonbo uses the only region that accepts new Agents. When several do, interactive `init` and `agent create` ask which one to use, and a non-interactive request without `--region` is refused with HTTP 400 (`region is required`). `init --region` applies only to an Agent that interactive `init` creates:
82
+
83
+ ```console
84
+ tonbo region list
85
+ tonbo agent create my-agent --region us-east-1
86
+ tonbo init --region us-east-1
87
+ ```
88
+
81
89
  `run` creates a durable Session and prints its ID in `--json` output. Pass that identity back to continue the same PI conversation:
82
90
 
83
91
  ```console
84
92
  tonbo run --session 4d8e9501-... "Continue the previous task."
85
93
  ```
86
94
 
95
+ Each `run` submits one durable Turn. Once the platform admits it, the Turn runs to its end whether or not the CLI keeps waiting, within the limits of `contracts/agents/turn-execution-v1.json` (today at most 60 minutes of execution, after waiting at most 70 minutes to start behind earlier Turns of the same Agent). `run` waits for the result as long as it takes: once the Turn is admitted it names the Turn and its Session on stderr and reads the Turn by id until it ends. It does not rely on the Management API's `202`, which comes only after a five-minute hold that outlasts how long Node's fetch waits for an answer; a submission that gets no answer before admission is sent again under the same id. Interrupting the wait, losing the network or signing out does not stop the Turn. `--no-wait` returns as soon as the Turn is admitted and prints how to collect its result later; `tonbo turn wait` does that from any bound directory whose signed-in account can use the Agent:
96
+
97
+ ```console
98
+ tonbo run --no-wait "Refactor the build and run the full test suite."
99
+ tonbo turn wait 7a000000-... --session 4d8e9501-...
100
+ ```
101
+
102
+ A failed Turn exits non-zero with its failure code, for example `Turn 7a000000-... failed (deadline_exceeded): ...`; the codes and their meaning are the `TurnFailureCode` schema of `contracts/management/openapi-v1.yaml`. `agent_busy` means the Agent already has the most Turns waiting to start and this one was not admitted; run it again later. Any other refusal also says the Turn was not admitted, and `run` stops sending a submission the platform has not admitted within 15 minutes. Waiting stops, without touching the Turn, a few minutes after the Turn's deadline if the platform has still not recorded its outcome.
103
+
87
104
  Every `tonbo deploy` creates one immutable Deployment: the uploaded source, the `.tonbo` settings and the git provenance of the source root (commit, branch, subject, author and whether the tree was dirty) when the directory is inside a git work tree. Git is optional; without it the Deployment records `tonbo deploy` as its source. Deployments are named by their commit subject, or by their short id (the first eight hex characters) when there is no commit, and every command that takes a Deployment accepts any unambiguous id prefix. The Agent's Production pointer names the one Deployment that serves its hostname; `deploy` moves Production to the new Deployment and prints the Deployment, its Production state and the exact standard OpenSSH command for the bound Agent and its username:
88
105
 
89
106
  ```console
@@ -117,7 +134,7 @@ tonbo deployments rollback 9b8c1d22 # or to a named one
117
134
 
118
135
  `tonbo deployments` (alias `tonbo deployments list`) marks the current Production Deployment with `●` and earlier ones with `○`; `STATUS` is the Deployment's relationship to Production: `current`, `promoting`, `failed`, `stopped`, `previous` or `none`. Promotion and rollback compare-and-swap the Production generation, so two people moving Production at once cannot silently overwrite each other; a conflict is reported and the command can be retried after checking `tonbo deployments`. `tonbo agent show` prints the Production Deployment and its state alongside the Agent addresses.
119
136
 
120
- Use `tonbo agent show` to rediscover the username, application address and SSH command later. `tonbo login` registers public keys found at the conventional `~/.ssh/id_ed25519.pub`, `id_ecdsa.pub` and `id_rsa.pub` paths. Register a key at another path with `tonbo ssh-key add <path.pub>` and revoke a lost or retired key with `tonbo ssh-key remove SHA256:...`. The CLI is not part of the SSH connection; ordinary OpenSSH, `scp` and `rsync` connect directly:
137
+ Use `tonbo agent show` to rediscover the username, home region, application address and SSH command later. `tonbo login` registers public keys found at the conventional `~/.ssh/id_ed25519.pub`, `id_ecdsa.pub` and `id_rsa.pub` paths. Register a key at another path with `tonbo ssh-key add <path.pub>` and revoke a lost or retired key with `tonbo ssh-key remove SHA256:...`. The CLI is not part of the SSH connection; ordinary OpenSSH, `scp` and `rsync` connect directly:
121
138
 
122
139
  ```console
123
140
  ssh my-agent.my-account@tonbo.sh
@@ -217,7 +234,7 @@ tonbo --json machine unbind <machine-uuid>
217
234
  tonbo --json machine release <machine-uuid>
218
235
  ```
219
236
 
220
- Only one active Agent binding per Machine and one Machine per Agent are currently supported. Unbinding drains execution and completes physical deletion, final usage settlement and storage credential revocation before the Machine becomes available. Wait for `available: true` in `machine show` before rebinding. Cross-Agent reassignment resets rootfs; the destination mounts its own Agent Workspace and Sessions. Hidden pause/resume within the same Agent retains rootfs and process state. Release also drains an existing binding automatically; it does not delete Agent files or Sessions.
237
+ Only one active Agent binding per Machine and one Machine per Agent are currently supported. Unbinding drains execution and completes physical deletion, final usage settlement and storage credential revocation before the Machine becomes available. Wait for `available: true` in `machine show` before rebinding. Cross-Agent reassignment resets rootfs; the destination mounts its own Agent Workspace and Sessions. Hidden pause/resume within the same Agent retains rootfs and process state. Release also drains an existing binding automatically; it does not delete Agent files or Sessions. A Machine binds only to an Agent in the same region. `machine list` names any region it could not reach in `unavailable_regions`; its Machines are missing from that answer.
221
238
 
222
239
  `tonbo agent list` lists accessible Agents without requiring a local `.tonbo` file. JSON command output uses `agent` for one Agent and `agents` for the list; there is no duplicate `agent` output alias.
223
240
 
package/dist/bin/tonbo.js CHANGED
@@ -16,7 +16,7 @@ var HttpError = class extends Error {
16
16
  this.body = body;
17
17
  }
18
18
  };
19
- async function requestJson(fetcher, url, init = {}) {
19
+ async function requestJsonResponse(fetcher, url, init = {}) {
20
20
  const response = await fetcher(url, init);
21
21
  const body = await response.json().catch(() => null);
22
22
  if (!response.ok) {
@@ -30,7 +30,10 @@ async function requestJson(fetcher, url, init = {}) {
30
30
  body
31
31
  );
32
32
  }
33
- return body;
33
+ return { body, headers: response.headers, status: response.status };
34
+ }
35
+ async function requestJson(fetcher, url, init = {}) {
36
+ return (await requestJsonResponse(fetcher, url, init)).body;
34
37
  }
35
38
 
36
39
  // src/contracts.ts
@@ -518,6 +521,13 @@ function assertManagedDeploymentSpec(value) {
518
521
  }
519
522
 
520
523
  // src/api.ts
524
+ function turnSubmission(response) {
525
+ if (response.status === 202) return { state: "pending" };
526
+ const body = response.body ?? {};
527
+ if (!body.data || typeof body.data.assistant_text !== "string")
528
+ throw new Error("Tonbo answered the Turn without its result.");
529
+ return { state: "completed", turn: body.data };
530
+ }
521
531
  var TonboApi = class {
522
532
  constructor(fetcher, accountOrigin = "https://tonbo.dev", managementOrigin = "https://api.tonbo.dev") {
523
533
  this.fetcher = fetcher;
@@ -562,7 +572,14 @@ var TonboApi = class {
562
572
  }
563
573
  ).then((body) => body.organizations);
564
574
  }
565
- createAgent(oauthToken, name, organizationId, requestId = randomUUID()) {
575
+ /** An omitted region lets the server choose the only region that accepts
576
+ * new Agents; once more than one does, it refuses the request. */
577
+ createAgent(oauthToken, {
578
+ name,
579
+ organizationId,
580
+ region,
581
+ requestId = randomUUID()
582
+ }) {
566
583
  return requestJson(
567
584
  this.fetcher,
568
585
  `${this.accountOrigin}/api/cli/agents`,
@@ -575,14 +592,15 @@ var TonboApi = class {
575
592
  body: JSON.stringify({
576
593
  name,
577
594
  requestId,
578
- ...organizationId ? { orgId: organizationId } : {}
595
+ ...organizationId ? { orgId: organizationId } : {},
596
+ ...region ? { region } : {}
579
597
  })
580
598
  }
581
599
  ).then((body) => body.agent).catch((cause) => {
582
600
  if (cause instanceof Error && "status" in cause && typeof cause.status === "number" && cause.status >= 400 && cause.status < 500)
583
601
  throw cause;
584
602
  throw new Error(
585
- `Agent creation was not confirmed. Query \`tonbo agent creation ${requestId}${organizationId ? ` --account ${organizationId}` : ""}\` or retry creation with --request-id ${requestId} and the same name and account.`,
603
+ `Agent creation was not confirmed. Query \`tonbo agent creation ${requestId}${organizationId ? ` --account ${organizationId}` : ""}\` or retry creation with --request-id ${requestId} and the same name, account and region.`,
586
604
  { cause }
587
605
  );
588
606
  });
@@ -741,14 +759,13 @@ var TonboApi = class {
741
759
  listRollouts(agentId, token) {
742
760
  return this.managementList(`/v1/agents/${agentId}/production/rollouts`, token);
743
761
  }
744
- async run({
762
+ /** Checks that Production is running and opens a durable Session, or
763
+ * reuses the given one. */
764
+ async openSession({
745
765
  agentId,
746
- prompt,
747
766
  sessionId,
748
- token,
749
- turnId = randomUUID()
767
+ token
750
768
  }) {
751
- const agentPath = `/v1/agents/${agentId}`;
752
769
  const production = await this.getProduction(agentId, token);
753
770
  if (!production)
754
771
  throw new Error("Agent has no Production Deployment; run `tonbo deploy` first.");
@@ -756,17 +773,42 @@ var TonboApi = class {
756
773
  throw new Error(
757
774
  `Production is ${production.observed_state}${production.last_error ? ` (${production.last_error})` : ""}; wait for it to be running.`
758
775
  );
759
- const session = sessionId ? { id: sessionId } : (await this.management("POST", `${agentPath}/sessions`, token, {
760
- deployment_id: production.deployment_id
761
- })).data;
762
- const turn = (await this.management(
776
+ const session = sessionId ? { id: sessionId } : (await this.management(
763
777
  "POST",
764
- `${agentPath}/sessions/${session.id}/turns`,
778
+ `/v1/agents/${agentId}/sessions`,
765
779
  token,
766
- { prompt },
767
- turnId
780
+ { deployment_id: production.deployment_id }
768
781
  )).data;
769
- return { production, session, turn };
782
+ return { production, session };
783
+ }
784
+ /** Submits one Turn under its own id; sending it again under the same id is
785
+ * answered from the same operation. The Management API holds the request
786
+ * for up to five minutes: it answers the result, 409 `turn_failed`, a
787
+ * refusal, or 202 while the Turn waits or runs. */
788
+ async submitTurn({
789
+ agentId,
790
+ prompt,
791
+ sessionId,
792
+ signal,
793
+ token,
794
+ turnId
795
+ }) {
796
+ return turnSubmission(
797
+ await this.managementResponse(
798
+ "POST",
799
+ `/v1/agents/${agentId}/sessions/${sessionId}/turns`,
800
+ token,
801
+ { body: { prompt }, idempotencyKey: turnId, signal }
802
+ )
803
+ );
804
+ }
805
+ /** One durable Turn; reading it never affects its execution. */
806
+ getTurn(agentId, sessionId, turnId, token) {
807
+ return this.management(
808
+ "GET",
809
+ `/v1/agents/${agentId}/sessions/${sessionId}/turns/${turnId}`,
810
+ token
811
+ ).then((response) => response.data);
770
812
  }
771
813
  turnEvents({
772
814
  after = 0,
@@ -803,17 +845,24 @@ var TonboApi = class {
803
845
  token
804
846
  );
805
847
  }
806
- management(method, path6, token, body, idempotencyKey) {
807
- return requestJson(this.fetcher, `${this.managementOrigin}${path6}`, {
848
+ management(method, path6, token, body) {
849
+ return this.managementResponse(method, path6, token, { body }).then(
850
+ (response) => response.body
851
+ );
852
+ }
853
+ managementResponse(method, path6, token, {
854
+ body,
855
+ idempotencyKey = randomUUID(),
856
+ signal
857
+ } = {}) {
858
+ return requestJsonResponse(this.fetcher, `${this.managementOrigin}${path6}`, {
808
859
  method,
809
860
  headers: {
810
861
  authorization: `Bearer ${token}`,
811
- ...body === void 0 ? {} : {
812
- "content-type": "application/json",
813
- "idempotency-key": idempotencyKey ?? randomUUID()
814
- }
862
+ ...body === void 0 ? {} : { "content-type": "application/json", "idempotency-key": idempotencyKey }
815
863
  },
816
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
864
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
865
+ ...signal ? { signal } : {}
817
866
  });
818
867
  }
819
868
  async managementList(path6, token) {
@@ -1107,13 +1156,15 @@ var AuthClient = class {
1107
1156
  this.accountOrigin = accountOrigin;
1108
1157
  this.browserOpener = browserOpener;
1109
1158
  }
1110
- async accessToken() {
1159
+ /** The stored access token, refreshed first when it has less than
1160
+ * `minimumLifetimeSeconds` left. */
1161
+ async accessToken(minimumLifetimeSeconds = 30) {
1111
1162
  const injected = process.env.TONBO_ACCESS_TOKEN?.trim();
1112
1163
  if (injected) return injected;
1113
1164
  const tokens = await this.credentials.load();
1114
1165
  if (!tokens) throw new Error("Not logged in. Run `tonbo login`.");
1115
1166
  const legacyRelativeExpiry = !tokens.expires_at && tokens.expires_in;
1116
- if (!legacyRelativeExpiry && (!tokens.expires_at || tokens.expires_at > Math.floor(Date.now() / 1e3) + 30))
1167
+ if (!legacyRelativeExpiry && (!tokens.expires_at || tokens.expires_at > Math.floor(Date.now() / 1e3) + minimumLifetimeSeconds))
1117
1168
  return tokens.access_token;
1118
1169
  if (!tokens.refresh_token) throw new Error("Tonbo login expired. Run `tonbo login` again.");
1119
1170
  const config = await this.config();
@@ -1716,14 +1767,105 @@ function agentNameSuggestion(value) {
1716
1767
  return name;
1717
1768
  }
1718
1769
 
1770
+ // src/turns.ts
1771
+ var TURN_OUTCOME_GRACE_MS = 3 * 60 * 1e3;
1772
+ var POLL_MIN_MS = 1e3;
1773
+ var POLL_MAX_MS = 1e4;
1774
+ var READ_FAILURE_LIMIT = 10;
1775
+ var TurnFailedError = class extends Error {
1776
+ constructor(turnId, failureCode, detail) {
1777
+ super(
1778
+ `Turn ${turnId} failed${failureCode ? ` (${failureCode})` : ""}${detail ? `: ${detail}` : "."}`
1779
+ );
1780
+ this.turnId = turnId;
1781
+ this.failureCode = failureCode;
1782
+ this.detail = detail;
1783
+ }
1784
+ };
1785
+ function problemCode(error) {
1786
+ if (!(error instanceof HttpError)) return null;
1787
+ const body = error.body && typeof error.body === "object" ? error.body : {};
1788
+ const code = body.code;
1789
+ return typeof code === "string" ? code : null;
1790
+ }
1791
+ function submissionProblem(error, turnId) {
1792
+ if (!(error instanceof HttpError)) return error;
1793
+ const body = error.body ?? {};
1794
+ const code = problemCode(error);
1795
+ if (error.status === 409 && code === "turn_failed")
1796
+ return new TurnFailedError(
1797
+ turnId,
1798
+ typeof body.failure_code === "string" ? body.failure_code : null,
1799
+ typeof body.detail === "string" ? body.detail : null
1800
+ );
1801
+ if (error.status === 409 && code === "agent_busy")
1802
+ return new Error(
1803
+ `Turn ${turnId} was not admitted: the Agent already has the most Turns waiting to start (agent_busy). Try again with a new Turn later.`,
1804
+ { cause: error }
1805
+ );
1806
+ return new Error(`Turn ${turnId} was not admitted: ${error.message}`, { cause: error });
1807
+ }
1808
+ function isUnauthenticated(error) {
1809
+ return error instanceof HttpError && error.status === 401;
1810
+ }
1811
+ function isNotFound(error) {
1812
+ return error instanceof HttpError && error.status === 404;
1813
+ }
1814
+ function isTransient(error) {
1815
+ if (error instanceof HttpError) return error.status === 429 || error.status >= 500;
1816
+ return error instanceof TypeError;
1817
+ }
1818
+ function turnWaitBound(turn) {
1819
+ const deadline = turn.deadline_at ?? turn.start_deadline_at;
1820
+ if (!deadline) return null;
1821
+ const at = Date.parse(deadline);
1822
+ return Number.isFinite(at) ? at + TURN_OUTCOME_GRACE_MS : null;
1823
+ }
1824
+ async function followTurn({
1825
+ now,
1826
+ read: read2,
1827
+ resume,
1828
+ sleep,
1829
+ turnId
1830
+ }) {
1831
+ let delay = POLL_MIN_MS;
1832
+ let failures = 0;
1833
+ for (; ; ) {
1834
+ let state;
1835
+ try {
1836
+ state = await read2();
1837
+ failures = 0;
1838
+ } catch (error) {
1839
+ const transient = isTransient(error);
1840
+ if (transient && (failures += 1) < READ_FAILURE_LIMIT) {
1841
+ await sleep(POLL_MAX_MS);
1842
+ continue;
1843
+ }
1844
+ if (!transient && !(error instanceof HttpError)) throw error;
1845
+ throw new Error(
1846
+ `${error instanceof Error ? error.message : String(error)} Waiting stopped, which does not stop the Turn; ${resume}`,
1847
+ { cause: error }
1848
+ );
1849
+ }
1850
+ if ("result" in state) return state.result;
1851
+ const bound = state.pending ? turnWaitBound(state.pending) : null;
1852
+ if (bound !== null && now() > bound)
1853
+ throw new Error(
1854
+ `Turn ${turnId} is still pending after its deadline, and the platform has not recorded its outcome. Waiting stopped, which does not stop the Turn; ${resume}`
1855
+ );
1856
+ await sleep(delay);
1857
+ delay = Math.min(delay * 2, POLL_MAX_MS);
1858
+ }
1859
+ }
1860
+
1719
1861
  // src/commands.ts
1720
- async function resolveAgent(deps) {
1862
+ async function resolveAgent(deps, minimumTokenLifetimeSeconds) {
1721
1863
  const root = await findDeclarationRoot(deps.cwd());
1722
1864
  const declaration = await loadDeclaration(root);
1723
1865
  if (!declaration.agent) {
1724
1866
  throw new Error("No Agent is configured in .tonbo. Run `tonbo agent use <agent>` first.");
1725
1867
  }
1726
- const oauthToken = await deps.auth.accessToken();
1868
+ const oauthToken = await deps.auth.accessToken(minimumTokenLifetimeSeconds);
1727
1869
  return {
1728
1870
  declaration,
1729
1871
  oauthToken,
@@ -1753,6 +1895,10 @@ async function initCommand(deps, options) {
1753
1895
  const overwrite = options.force === true || exists && interactive;
1754
1896
  if (exists && !overwrite)
1755
1897
  throw new Error(`${DECLARATION_FILENAME} already exists. Pass --force to replace it.`);
1898
+ if (options.region !== void 0 && !interactive)
1899
+ throw new Error(
1900
+ "--region applies only to an agent that interactive init creates. Run `tonbo agent create <name> --region <region>` instead."
1901
+ );
1756
1902
  const inspection = await deps.inspectSource(root);
1757
1903
  let harness = options.harness ?? (existingDeclaration ? "pi" : void 0);
1758
1904
  if (harness !== void 0 && harness !== "pi") {
@@ -1799,7 +1945,7 @@ async function initCommand(deps, options) {
1799
1945
  publicHostname: existingDeclaration.agent
1800
1946
  } : { kind: "later" };
1801
1947
  if (interactive && !existingDeclaration?.agent) {
1802
- agentPlan = await selectInitAgent(deps, root);
1948
+ agentPlan = await selectInitAgent(deps, root, options.region);
1803
1949
  }
1804
1950
  if (interactive) {
1805
1951
  while (true) {
@@ -1842,7 +1988,7 @@ async function initCommand(deps, options) {
1842
1988
  ...driver === "command" ? [{ description: entry, name: "PI SDK entry file", value: "entry" }] : [],
1843
1989
  { name: "Back to review", value: "back" }
1844
1990
  ]);
1845
- if (setting === "agent") agentPlan = await selectInitAgent(deps, root);
1991
+ if (setting === "agent") agentPlan = await selectInitAgent(deps, root, options.region);
1846
1992
  if (setting === "harness") harness = await selectHarness(deps);
1847
1993
  if (setting === "driver") {
1848
1994
  driver = await deps.select(
@@ -1871,7 +2017,11 @@ async function initCommand(deps, options) {
1871
2017
  let createdAgent;
1872
2018
  if (agentPlan.kind === "create") {
1873
2019
  const oauthToken = await deps.auth.accessToken();
1874
- createdAgent = await deps.api.createAgent(oauthToken, agentPlan.name, agentPlan.organizationId);
2020
+ createdAgent = await deps.api.createAgent(oauthToken, {
2021
+ name: agentPlan.name,
2022
+ organizationId: agentPlan.organizationId,
2023
+ region: agentPlan.region
2024
+ });
1875
2025
  agentHostname = createdAgent.publicHostname;
1876
2026
  }
1877
2027
  const configuredDeclaration = createDeclaration(
@@ -1904,9 +2054,11 @@ Build: ${buildCommand?.join(" ")}
1904
2054
  Entrypoint: node ${entry}` : inspection.pi.packageCount > 0 ? `PI CLI with ${inspection.pi.packageCount} package${inspection.pi.packageCount === 1 ? "" : "s"}` : "PI CLI";
1905
2055
  const agentSummary = createdAgent ? `Agent: ${createdAgent.name} (${createdAgent.publicHostname})
1906
2056
  Organization: ${createdAgent.organizationName}
2057
+ Region: ${createdAgent.region}
1907
2058
  Application: https://${createdAgent.publicHostname}
1908
2059
  SSH after deploy: ssh ${createdAgent.sshDestination}` : agentPlan.kind === "existing" ? `Agent: ${agentPlan.name ? `${agentPlan.name} (${agentPlan.publicHostname})` : agentPlan.publicHostname}${agentPlan.organizationName ? `
1909
- Organization: ${agentPlan.organizationName}` : ""}${agentPlan.sshDestination ? `
2060
+ Organization: ${agentPlan.organizationName}` : ""}${agentPlan.region ? `
2061
+ Region: ${agentPlan.region}` : ""}${agentPlan.sshDestination ? `
1910
2062
  SSH after deploy: ssh ${agentPlan.sshDestination}` : ""}` : "Next: tonbo agent create <name>";
1911
2063
  deps.output({
1912
2064
  message: `${createdAgent ? `Created agent ${createdAgent.name} and` : exists ? "Updated" : "Created"} ${DECLARATION_FILENAME}.
@@ -1923,7 +2075,7 @@ function initSaveLabel(exists, agent) {
1923
2075
  }
1924
2076
  return exists ? `Replace ${DECLARATION_FILENAME}` : `Create ${DECLARATION_FILENAME}`;
1925
2077
  }
1926
- async function selectInitAgent(deps, root) {
2078
+ async function selectInitAgent(deps, root, requestedRegion) {
1927
2079
  for (; ; ) {
1928
2080
  const action = await deps.select("How should this directory connect to Tonbo?", [
1929
2081
  {
@@ -1945,6 +2097,7 @@ async function selectInitAgent(deps, root) {
1945
2097
  if (action === "later") return { kind: "later" };
1946
2098
  if (action === "create") {
1947
2099
  const organization = await selectInitOrganization(deps);
2100
+ const region = requestedRegion ?? await selectAgentRegion(deps);
1948
2101
  const defaultName = agentNameSuggestion(path4.basename(root)) || "tonbo-agent";
1949
2102
  for (; ; ) {
1950
2103
  const answer = (await deps.prompt(`Agent name [${defaultName}]: `)).trim().toLowerCase();
@@ -1955,7 +2108,8 @@ async function selectInitAgent(deps, root) {
1955
2108
  kind: "create",
1956
2109
  name,
1957
2110
  organizationId: organization?.id,
1958
- organizationName: organization?.name
2111
+ organizationName: organization?.name,
2112
+ ...region ? { region } : {}
1959
2113
  };
1960
2114
  } catch (error) {
1961
2115
  deps.output({
@@ -1976,7 +2130,7 @@ async function selectInitAgent(deps, root) {
1976
2130
  "Which agent should this directory use?",
1977
2131
  agents.map((agent2) => ({
1978
2132
  name: agent2.name,
1979
- description: `Organization: ${agent2.organizationName} \xB7 ${agent2.publicHostname} \xB7 ${agent2.id}`,
2133
+ description: `Organization: ${agent2.organizationName} \xB7 ${agent2.region} \xB7 ${agent2.publicHostname} \xB7 ${agent2.id}`,
1980
2134
  value: agent2.id
1981
2135
  }))
1982
2136
  );
@@ -1986,13 +2140,23 @@ async function selectInitAgent(deps, root) {
1986
2140
  name: agent.name,
1987
2141
  organizationName: agent.organizationName,
1988
2142
  publicHostname: agent.publicHostname,
2143
+ region: agent.region,
1989
2144
  sshDestination: agent.sshDestination
1990
2145
  };
1991
2146
  }
1992
2147
  }
2148
+ async function selectAgentRegion(deps) {
2149
+ const { regions } = await deps.api.listRegions(await deps.auth.accessToken());
2150
+ const open3 = regions.filter((region) => region.allocation_enabled);
2151
+ if (open3.length < 2) return void 0;
2152
+ return deps.select(
2153
+ "Which region should hold the new agent? It stays there permanently.",
2154
+ open3.map((region) => ({ description: region.id, name: region.display_name, value: region.id }))
2155
+ );
2156
+ }
1993
2157
  function initAgentSummary(agent) {
1994
2158
  if (agent.kind === "create")
1995
- return `Create ${agent.name}${agent.organizationName ? ` in ${agent.organizationName}` : ""} (permanent address assigned after creation)`;
2159
+ return `Create ${agent.name}${agent.organizationName ? ` in ${agent.organizationName}` : ""}${agent.region ? `, region ${agent.region}` : ""} (permanent address assigned after creation)`;
1996
2160
  if (agent.kind === "existing") {
1997
2161
  return agent.name ? `${agent.name}${agent.organizationName ? ` in ${agent.organizationName}` : ""} (${agent.publicHostname})` : `Keep ${agent.publicHostname}`;
1998
2162
  }
@@ -2166,7 +2330,13 @@ async function agentCreateCommand(deps, name, options = {}) {
2166
2330
  const oauthToken = await deps.auth.accessToken();
2167
2331
  validateAgentName(name);
2168
2332
  const requestId = options.requestId ?? randomUUID3();
2169
- const agent = await deps.api.createAgent(oauthToken, name, options.account, requestId);
2333
+ const region = options.region ?? (deps.interactive() ? await selectAgentRegion(deps) : void 0);
2334
+ const agent = await deps.api.createAgent(oauthToken, {
2335
+ name,
2336
+ organizationId: options.account,
2337
+ region,
2338
+ requestId
2339
+ });
2170
2340
  try {
2171
2341
  await bindDeclarationAgent(root, agent.publicHostname);
2172
2342
  } catch (error) {
@@ -2176,7 +2346,13 @@ async function agentCreateCommand(deps, name, options = {}) {
2176
2346
  );
2177
2347
  }
2178
2348
  deps.output({
2179
- message: `Created agent ${agent.name} and bound it in .tonbo.`,
2349
+ message: [
2350
+ `Created agent ${agent.name} and bound it in .tonbo.`,
2351
+ `Organization: ${agent.organizationName}`,
2352
+ `Region: ${agent.region}`,
2353
+ `Application: https://${agent.publicHostname}`,
2354
+ `SSH after deploy: ssh ${agent.sshDestination}`
2355
+ ].join("\n"),
2180
2356
  agent,
2181
2357
  requestId
2182
2358
  });
@@ -2224,25 +2400,223 @@ async function deployCommand(deps, options = { promote: true }) {
2224
2400
  }
2225
2401
  deps.output({ message: lines.join("\n"), agent, ...result });
2226
2402
  }
2227
- async function runCommand(deps, prompt, options) {
2228
- const { oauthToken, agent } = await resolveAgent(deps);
2229
- const managementToken = await deps.api.exchangeManagementToken(oauthToken, agent.id);
2230
- const result = await deps.api.run({
2231
- agentId: agent.id,
2232
- prompt,
2233
- sessionId: options.session,
2234
- token: managementToken
2403
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2404
+ var ADMISSION_WAIT_MS = 15 * 60 * 1e3;
2405
+ var RESUBMIT_MS = 5e3;
2406
+ var SUBMISSION_TOKEN_LIFETIME_SECONDS = 240;
2407
+ function managementTokens(deps, agentId, oauthToken) {
2408
+ let current = deps.api.exchangeManagementToken(oauthToken, agentId);
2409
+ return async function withToken(call) {
2410
+ const token = await current;
2411
+ try {
2412
+ return await call(token);
2413
+ } catch (error) {
2414
+ if (!isUnauthenticated(error)) throw error;
2415
+ current = deps.auth.accessToken().then((oauth) => deps.api.exchangeManagementToken(oauth, agentId));
2416
+ return call(await current);
2417
+ }
2418
+ };
2419
+ }
2420
+ function waitCommand(turnId, sessionId) {
2421
+ return `tonbo turn wait ${turnId} --session ${sessionId}`;
2422
+ }
2423
+ function turnNotFound(turnId, sessionId, agentName) {
2424
+ return new Error(`Turn ${turnId} was not found in Session ${sessionId} of agent ${agentName}.`);
2425
+ }
2426
+ function sessionTurn(turn) {
2427
+ return {
2428
+ assistant_text: turn.assistant_text ?? "",
2429
+ session_id: turn.session_id,
2430
+ turn_id: turn.id
2431
+ };
2432
+ }
2433
+ function holdSubmission(deps, withToken, submission) {
2434
+ const controller = new AbortController();
2435
+ const held = { controller, settled: Promise.resolve() };
2436
+ held.settled = withToken(
2437
+ (token) => deps.api.submitTurn({ ...submission, signal: controller.signal, token })
2438
+ ).then(
2439
+ (value) => {
2440
+ held.answer = { value };
2441
+ },
2442
+ (error) => {
2443
+ held.answer = { error };
2444
+ }
2445
+ );
2446
+ return held;
2447
+ }
2448
+ async function submitAndFollow(deps, withToken, submission, { agentName, outcome }) {
2449
+ const { agentId, sessionId, turnId } = submission;
2450
+ const now = deps.now ?? Date.now;
2451
+ const sleep = deps.sleep ?? pause;
2452
+ const submittedAt = now();
2453
+ const state = {
2454
+ admitted: false,
2455
+ announced: false,
2456
+ held: holdSubmission(deps, withToken, submission),
2457
+ resubmitAt: 0,
2458
+ unanswered: null
2459
+ };
2460
+ const read2 = async () => {
2461
+ const answer = state.held?.answer;
2462
+ if (answer) {
2463
+ state.held = null;
2464
+ if ("error" in answer) {
2465
+ const problem = submissionProblem(answer.error, turnId);
2466
+ if (problem instanceof TurnFailedError) throw problem;
2467
+ if (!state.admitted) {
2468
+ if (!isTransient(answer.error)) throw problem;
2469
+ state.unanswered = answer.error;
2470
+ state.resubmitAt = now() + RESUBMIT_MS;
2471
+ }
2472
+ } else if (answer.value.state === "completed") {
2473
+ return { result: { completed: answer.value.turn } };
2474
+ }
2475
+ }
2476
+ let turn = null;
2477
+ try {
2478
+ turn = await withToken((token) => deps.api.getTurn(agentId, sessionId, turnId, token));
2479
+ } catch (error) {
2480
+ if (!isNotFound(error)) throw error;
2481
+ if (state.admitted) throw turnNotFound(turnId, sessionId, agentName);
2482
+ }
2483
+ if (turn) {
2484
+ state.admitted = true;
2485
+ if (turn.status === "completed") return { result: { completed: sessionTurn(turn) } };
2486
+ if (turn.status === "failed")
2487
+ throw new TurnFailedError(turn.id, turn.failure_code, turn.failure);
2488
+ if (!outcome) return { result: { admitted: turn } };
2489
+ if (!state.announced) {
2490
+ state.announced = true;
2491
+ deps.progress.start(
2492
+ `Turn ${turnId} is running on the platform in Session ${sessionId}; waiting for its result. Interrupting the wait does not stop the Turn.`
2493
+ );
2494
+ }
2495
+ return { pending: turn };
2496
+ }
2497
+ if (now() - submittedAt > ADMISSION_WAIT_MS) {
2498
+ const cause = state.unanswered;
2499
+ throw new Error(
2500
+ `Turn ${turnId} was not admitted within ${ADMISSION_WAIT_MS / 6e4} minutes${cause instanceof Error ? ` (the last submission got no answer: ${cause.message})` : ""}. Waiting stopped; if the platform admits it later, \`${waitCommand(turnId, sessionId)}\` follows it.`,
2501
+ { cause }
2502
+ );
2503
+ }
2504
+ if (!state.held && now() >= state.resubmitAt)
2505
+ state.held = holdSubmission(deps, withToken, submission);
2506
+ return { pending: null };
2507
+ };
2508
+ try {
2509
+ const followed = await followTurn({
2510
+ now,
2511
+ read: read2,
2512
+ resume: `wait for it with \`${waitCommand(turnId, sessionId)}\`.`,
2513
+ // An answer to the held submission ends a pause at once.
2514
+ sleep: async (ms) => {
2515
+ const held = state.held;
2516
+ if (!held) return sleep(ms);
2517
+ const wake = new AbortController();
2518
+ await Promise.race([sleep(ms, wake.signal), held.settled]);
2519
+ wake.abort();
2520
+ },
2521
+ turnId
2522
+ });
2523
+ if (state.announced) deps.progress.succeed(`Turn ${turnId} completed.`);
2524
+ return followed;
2525
+ } catch (error) {
2526
+ if (state.announced) deps.progress.fail();
2527
+ throw error;
2528
+ } finally {
2529
+ state.held?.controller.abort();
2530
+ }
2531
+ }
2532
+ function pause(ms, signal) {
2533
+ return new Promise((resolve) => {
2534
+ const timer = setTimeout(resolve, ms);
2535
+ signal?.addEventListener(
2536
+ "abort",
2537
+ () => {
2538
+ clearTimeout(timer);
2539
+ resolve();
2540
+ },
2541
+ { once: true }
2542
+ );
2235
2543
  });
2544
+ }
2545
+ async function runCommand(deps, prompt, options) {
2546
+ const { oauthToken, agent } = await resolveAgent(deps, SUBMISSION_TOKEN_LIFETIME_SECONDS);
2547
+ const withToken = managementTokens(deps, agent.id, oauthToken);
2548
+ const { production, session } = await withToken(
2549
+ (token) => deps.api.openSession({ agentId: agent.id, sessionId: options.session, token })
2550
+ );
2551
+ const turnId = randomUUID3();
2552
+ const followed = await submitAndFollow(
2553
+ deps,
2554
+ withToken,
2555
+ { agentId: agent.id, prompt, sessionId: session.id, turnId },
2556
+ { agentName: agent.name, outcome: options.wait !== false }
2557
+ );
2558
+ if ("admitted" in followed) {
2559
+ deps.output({
2560
+ message: [
2561
+ `Turn ${turnId} was admitted in Session ${session.id}; the platform runs it to its end.`,
2562
+ "Wait for its result:",
2563
+ ` ${waitCommand(turnId, session.id)}`
2564
+ ].join("\n"),
2565
+ agent,
2566
+ production,
2567
+ session,
2568
+ turn_id: turnId,
2569
+ turn: followed.admitted
2570
+ });
2571
+ return;
2572
+ }
2236
2573
  deps.output({
2237
- message: result.turn.assistant_text,
2574
+ message: followed.completed.assistant_text,
2238
2575
  agent,
2239
- ...result
2576
+ production,
2577
+ session,
2578
+ turn: followed.completed
2240
2579
  });
2241
2580
  }
2581
+ async function turnWaitCommand(deps, turnId, options) {
2582
+ if (!UUID.test(turnId) || !UUID.test(options.session))
2583
+ throw new Error("The Turn and its Session are identified by UUIDs.");
2584
+ const { oauthToken, agent } = await resolveAgent(deps);
2585
+ const withToken = managementTokens(deps, agent.id, oauthToken);
2586
+ deps.progress.start(`Waiting for Turn ${turnId}. Interrupting the wait does not stop the Turn.`);
2587
+ try {
2588
+ const turn = await followTurn({
2589
+ now: deps.now ?? Date.now,
2590
+ read: async () => {
2591
+ let current;
2592
+ try {
2593
+ current = await withToken(
2594
+ (token) => deps.api.getTurn(agent.id, options.session, turnId, token)
2595
+ );
2596
+ } catch (error) {
2597
+ if (!isNotFound(error)) throw error;
2598
+ throw turnNotFound(turnId, options.session, agent.name);
2599
+ }
2600
+ if (current.status === "completed") return { result: current };
2601
+ if (current.status === "failed")
2602
+ throw new TurnFailedError(current.id, current.failure_code, current.failure);
2603
+ return { pending: current };
2604
+ },
2605
+ resume: `run \`${waitCommand(turnId, options.session)}\` again.`,
2606
+ sleep: deps.sleep ?? pause,
2607
+ turnId
2608
+ });
2609
+ deps.progress.succeed(`Turn ${turnId} completed.`);
2610
+ deps.output({ message: turn.assistant_text ?? "", agent, turn });
2611
+ } catch (error) {
2612
+ deps.progress.fail();
2613
+ throw error;
2614
+ }
2615
+ }
2242
2616
  async function agentListCommand(deps) {
2243
2617
  const agents = await deps.api.listAgents(await deps.auth.accessToken());
2244
2618
  deps.output({
2245
- message: agents.length ? agents.map((agent) => `${agent.name} \xB7 ${agent.publicHostname}`).join("\n") : "No agents are available.",
2619
+ message: agents.length ? agents.map((agent) => `${agent.name} \xB7 ${agent.publicHostname} \xB7 ${agent.region}`).join("\n") : "No agents are available.",
2246
2620
  agents
2247
2621
  });
2248
2622
  }
@@ -2253,6 +2627,7 @@ async function agentShowCommand(deps) {
2253
2627
  const lines = [
2254
2628
  `agent: ${agent.name}`,
2255
2629
  `Organization: ${agent.organizationName}`,
2630
+ `region: ${agent.region}`,
2256
2631
  `application: https://${agent.publicHostname}`,
2257
2632
  `ssh: ssh ${agent.sshDestination}`
2258
2633
  ];
@@ -2746,7 +3121,7 @@ function jsonOutput(program) {
2746
3121
  }
2747
3122
  function createProgram(dependencies = createDependencies) {
2748
3123
  const program = new Command().name("tonbo").description("Deploy a persistent Agent to Tonbo.").version(packageVersion.version).option("--json", "print machine-readable JSON");
2749
- program.command("init").description("interactively create a Tonbo Agent declaration in this directory").option("--harness <harness>", "Agent Harness (currently pi)").option("--model <model>", "inference model").option("--driver <driver>", "PI driver: native or command").option("--agent-entry <file>", "Node entry file for the command driver").option("--build-command <argv...>", "build command argv for the command driver").option("--force", `replace an existing ${DECLARATION_FILENAME}`).action(
3124
+ program.command("init").description("interactively create a Tonbo Agent declaration in this directory").option("--harness <harness>", "Agent Harness (currently pi)").option("--model <model>", "inference model").option("--driver <driver>", "PI driver: native or command").option("--agent-entry <file>", "Node entry file for the command driver").option("--build-command <argv...>", "build command argv for the command driver").option("--region <region>", "home region of an agent init creates (see tonbo region list)").option("--force", `replace an existing ${DECLARATION_FILENAME}`).action(
2750
3125
  async (options) => initCommand(dependencies(jsonOutput(program)), options)
2751
3126
  );
2752
3127
  program.command("login").description("sign in through the browser and store the session in the user config").action(async () => loginCommand(dependencies(jsonOutput(program))));
@@ -2830,7 +3205,10 @@ function createProgram(dependencies = createDependencies) {
2830
3205
  );
2831
3206
  });
2832
3207
  const agent = program.command("agent").description("manage the Agent bound to this source directory");
2833
- agent.command("create <name>").description("create an agent and bind this directory to it").option("--account <account>", "owning account UUID; defaults to your personal account").option("--request-id <uuid>", "reuse the same creation UUID after an uncertain response").action(
3208
+ agent.command("create <name>").description("create an agent and bind this directory to it").option("--account <account>", "owning account UUID; defaults to your personal account").option(
3209
+ "--region <region>",
3210
+ "permanent home region (see tonbo region list); required once several regions accept new agents"
3211
+ ).option("--request-id <uuid>", "reuse the same creation UUID after an uncertain response").action(
2834
3212
  async (name, options) => agentCreateCommand(dependencies(jsonOutput(program)), name, options)
2835
3213
  );
2836
3214
  agent.command("creation <requestId>").description("query the original Agent for a creation request").option("--account <account>", "owning account UUID; defaults to your personal account").action(async (requestId, options) => {
@@ -2870,9 +3248,17 @@ function createProgram(dependencies = createDependencies) {
2870
3248
  deployments.command("rollback [deployment]").description("move Production back to the previous Production Deployment, or to a named one").action(
2871
3249
  async (prefix) => deploymentsRollbackCommand(dependencies(jsonOutput(program)), prefix)
2872
3250
  );
2873
- program.command("run <prompt>").description("run one prompt in a durable agent session").option("--session <session>", "resume an existing Agent Session UUID").action(
3251
+ program.command("run <prompt>").description("run one prompt as a durable Turn in an agent session and wait for its result").option("--session <session>", "resume an existing Agent Session UUID").option(
3252
+ "--no-wait",
3253
+ "return once the Turn is admitted; the platform runs it to its end (see tonbo turn wait)"
3254
+ ).action(
2874
3255
  async (prompt, options) => runCommand(dependencies(jsonOutput(program)), prompt, options)
2875
3256
  );
3257
+ program.command("turn").description("follow durable Turns of the bound agent").command("wait <turn>").description(
3258
+ "wait for a Turn's result; the Turn runs on the platform whether or not anyone waits"
3259
+ ).requiredOption("--session <session>", "the Turn's Agent Session UUID").action(
3260
+ async (turnId, options) => turnWaitCommand(dependencies(jsonOutput(program)), turnId, options)
3261
+ );
2876
3262
  const secret = program.command("secret").description("manage encrypted environment secrets for the agent service");
2877
3263
  secret.command("list").action(async () => secretListCommand(dependencies(jsonOutput(program))));
2878
3264
  secret.command("set <name>").description("set a secret from an environment variable (the same name by default)").option("--from-env <name>", "read the value from another environment variable").action(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Deploy persistent Agents and manage their Machines from the command line.",
5
5
  "homepage": "https://tonbo.dev",
6
6
  "bugs": {