carthagent 1.1.0-beta.1 → 1.1.0-beta.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.
Files changed (34) hide show
  1. package/docs/beta2-published-acceptance.md +79 -0
  2. package/docs/cloud-onboarding-acceptance.md +68 -0
  3. package/docs/release-notes-beta.2.md +60 -0
  4. package/docs/release-notes-beta.3.md +47 -0
  5. package/lib/cloud/client.mjs +13 -7
  6. package/lib/opencode/cli-runtime.mjs +56 -18
  7. package/lib/opencode/cli.mjs +10 -6
  8. package/lib/opencode/provider-policy.mjs +1 -1
  9. package/lib/opencode/runtime-release.json +11 -11
  10. package/lib/providers/names.mjs +1 -1
  11. package/lib/tui/models.mjs +1 -1
  12. package/package.json +1 -1
  13. package/plugins/carthagent/src/rpc.ts +2 -2
  14. package/plugins/carthagent/src/server/providers.ts +10 -9
  15. package/plugins/carthagent/src/server/ship-rpc.ts +12 -10
  16. package/plugins/carthagent/src/tui.tsx +18 -8
  17. package/plugins/carthagent/src/ui/client.ts +1 -1
  18. package/plugins/carthagent/src/ui/commands.ts +2 -2
  19. package/plugins/carthagent/src/ui/daily-quests.tsx +1 -1
  20. package/plugins/carthagent/src/ui/voyages.tsx +1 -1
  21. package/tests/cli.test.mjs +2 -2
  22. package/tests/cloud-client.test.mjs +2 -2
  23. package/tests/experiential-extension.test.mjs +2 -2
  24. package/tests/experiential.test.mjs +1 -1
  25. package/tests/native-provider-policy.test.mjs +4 -2
  26. package/tests/native-ship-rpc.test.mjs +14 -2
  27. package/tests/opencode-cli.test.mjs +38 -4
  28. package/tests/opencode-providers.test.mjs +4 -3
  29. package/tests/opencode-ship-rpc.test.mjs +2 -2
  30. package/tests/tui.test.mjs +2 -2
  31. package/tools/native-fixture-stop-diagnostics.mjs +3 -1
  32. package/tools/native-terminal-semantics.py +7 -1
  33. package/tools/test-native-host.mjs +4 -2
  34. package/tools/test-native-ship-models.mjs +1 -1
@@ -0,0 +1,79 @@
1
+ # Beta.2 release acceptance
2
+
3
+ November 2, 2026. Immutable tag `v1.1.0-beta.2` points to
4
+ `e14aeb17c213a927a27e9522da65db5928219647`.
5
+
6
+ ## Completed
7
+
8
+ - Native onboarding PR #238 and first-task guidance PR #239 merged. Release PR
9
+ #240 merged with tag ancestry preserved at `fc5382364f41b1563ed137ed0db0c5711e041a83`.
10
+ - Five-platform prebuild [41505735348](https://github.com/B-icy/Carthagent/actions/runs/41505735348)
11
+ passed. Local manifest regeneration verified all archive/executable hashes and
12
+ embedded provenance against the pinned runtime recipe.
13
+ - Release CI [41509102387](https://github.com/B-icy/Carthagent/actions/runs/41509102387)
14
+ passed across Linux, macOS and Windows, including installed-package acceptance.
15
+ - Final draft acceptance [41511681097](https://github.com/B-icy/Carthagent/actions/runs/41511681097)
16
+ passed against the exact tag, assets and main ancestry.
17
+ - GitHub prerelease is public with five runtime archives, the manifest and
18
+ combined checksums. Independent anonymous verification of all five public
19
+ archives passed.
20
+ - Release workflow [41512736590](https://github.com/B-icy/Carthagent/actions/runs/41512736590)
21
+ passed anonymous installed-package acceptance and reports successful npm
22
+ publication with signed provenance (transparency log index 3648230788).
23
+ - Backend onboarding #178 and retention reporting #179 are deployed. Live
24
+ authenticated account identity and read-only plan terms were verified earlier.
25
+
26
+ ## npm publication verified
27
+
28
+ After the workflow reported `+ carthagent@1.1.0-beta.2`, public registry requests
29
+ from the acceptance environment still returned 404 for that exact version.
30
+ Both npm CLI and cache-bypassed package metadata initially reported
31
+ `next=1.1.0-beta.1`, `latest=1.0.7`. Registry visibility subsequently converged;
32
+ no repeat publication was attempted.
33
+
34
+ The public beta.2 tarball's SHA-512 matches registry integrity and the provenance
35
+ subject digest. Sigstore verification passed for the GitHub Actions issuer and
36
+ the repository's `release.yml@refs/heads/main` identity. The statement identifies
37
+ release workflow 41512736590, and package `gitHead` matches the immutable tag.
38
+ Verified tags: `next=1.1.0-beta.2`, `latest=1.0.7`.
39
+ Evidence: `/tmp/opencode/beta2-npm-acceptance.json`.
40
+
41
+ ## Installed update verified
42
+
43
+ The website command `npm install -g carthagent@next` installed beta.2 into an
44
+ isolated Linux x64 home/prefix. Default runtime acquisition resolved the pinned
45
+ CTG-owned executable with SHA-256
46
+ `8a34735274c0b62049f6ae882dc955016fde54458efc18822fbcedb2e8eddeda`.
47
+ The beta update check reported the installed version as current.
48
+ Evidence: `/tmp/opencode/beta2-clean-install-acceptance.json`.
49
+
50
+ An isolated npm installation of beta.1 detected beta.2 with
51
+ `ctg update --channel beta --check`. Running `ctg update --channel beta` upgraded
52
+ it to beta.2; a profile sentinel remained byte-for-byte intact. This used a
53
+ disposable home and global npm prefix, not the user's normal installation.
54
+ Evidence: `/tmp/opencode/beta2-update-acceptance.json`.
55
+
56
+ ## Remaining acceptance
57
+
58
+ Stripe sandbox access subsequently became available. Real purchases, upgrades,
59
+ SCA, renewal, failed renewal and cancellation were exercised separately against
60
+ the isolated backend (see backend `docs/stripe-sandbox-acceptance.md`).
61
+
62
+ An isolated published beta.2 login connected the authorized existing Cloud account.
63
+ A real range-fix task passed four independent tests. Continuing that same session
64
+ hit its persistent elapsed-time budget; beta.2 showed only an unexplained interrupt.
65
+ The follow-up source fix adds actionable budget diagnostics and explicit
66
+ `--reset-budget` support. With that source launcher and the same installed runtime,
67
+ profile and session, continuation added an ascending-across-zero regression and
68
+ passed all five independent tests. This launcher fix is not part of immutable beta.2.
69
+ Execution budgets are not billing credit and never reset on payment or ordinary
70
+ continuation. Evidence: `/tmp/opencode/stripe-acceptance/live-continuation-fixed.log`.
71
+
72
+ Full task continuation across a genuine payment boundary remains incomplete:
73
+ the real model task used existing Cloud credit and sandbox payments used a separate
74
+ backend without a model upstream. Screenshot approval is not claimed. Local terminal timing failures
75
+ were followed by passing full local, CI and draft acceptance; this does not prove
76
+ that every terminal timing issue is resolved.
77
+
78
+ The normal installed profile and Invest-With-Friends files were not changed by
79
+ these release checks. Stable npm `latest` remains 1.0.7.
@@ -0,0 +1,68 @@
1
+ # Cloud onboarding — beta.2 implementation
2
+
3
+ November 2, 2026. Beta.2 is published on GitHub and npm `next`; `latest` remains
4
+ 1.0.7. See [published acceptance](beta2-published-acceptance.md) for final CI,
5
+ draft/public runtime gates, signed npm provenance, Linux clean installation and
6
+ isolated beta.1 → beta.2 update evidence. Historical preparation notes below do
7
+ not supersede that release record.
8
+
9
+ Native PR #238 merged at `a5ad2164d056fee7f7183579ce6cc97305bae53b` after CI
10
+ 41504674623 passed. First-task guidance is tracked in PR #239. Beta.2 runtime
11
+ prebuild 41505735348 succeeded. All five downloaded archives, executable digests
12
+ and pinned provenance were independently verified by regenerating the candidate
13
+ manifest locally. Package and lockfile target beta.2 and pin that exact manifest;
14
+ publication remains pending.
15
+
16
+ ## Changes
17
+
18
+ - Use Carthagent Cloud for hosted models; retain Ship mode, internal IDs and
19
+ compatibility login aliases.
20
+ - Fresh device sign-in has one primary flow. Legacy import and upstream key
21
+ configuration require `CARTHAGENT_ADVANCED_AUTH=1`.
22
+ - Billing selects Builder/Pro/Max and retrieves authenticated server quote terms.
23
+ TUI review precedes opening checkout. Existing subscribers review exact Stripe
24
+ proration in the browser before confirming a change.
25
+ - `/billing refresh` and `ctg billing refresh` show current account access and
26
+ require explicit continuation in the existing task session.
27
+ - Cloud account text identifies the signed-in email; usage recovery distinguishes
28
+ starter credit, daily/monthly limits, billing and own-provider access.
29
+
30
+ ## Verification
31
+
32
+ - Full native suite: 1,131 passed, 5 skipped, 0 failed.
33
+ `/tmp/opencode/onboarding-native-reviewed.log`
34
+ - Native build and typecheck pass.
35
+ `/tmp/opencode/onboarding-native-reviewed-build.log`
36
+ - RPC regression verifies that eligibility comes from the authenticated server
37
+ and quoting does not create a checkout.
38
+ - Native host acceptance passes at `b715ff282b` after correcting stale fresh-auth
39
+ and Cloud-name expectations. Both CI installed-package jobs passed. Run
40
+ 41502591162 passed Linux/macOS and Windows 24; Windows 22 hit the two-second
41
+ PowerShell diagnostics timeout, while its parallel run passed. Windows
42
+ diagnostics now allow bounded ten-second cold startup.
43
+ - Installed package acceptance and its focused terminal rerun reproduced a
44
+ reconnect overlay. DEBUG-enabled source and installed probes passed; a normal
45
+ source run subsequently hit its existing terminal-snapshot deadline. This is
46
+ timing-sensitive local acceptance, not proof of a fixed runtime defect. Both
47
+ earlier CI package checks passed, and corrected CI passed before merge.
48
+ - The subsequent full non-focused installed-package run passed all 28
49
+ probes/scenarios, including terminal semantics, using the package-pinned
50
+ beta.1 owned runtime (`/tmp/opencode/onboarding-native-package-final.log`).
51
+ This closes the local rerun gate for the onboarding source; beta.2 still
52
+ requires acceptance against its own final packaged manifest and release assets.
53
+
54
+ ## Outstanding gates
55
+
56
+ Backend counterpart: `B-icy/Carthagent-Backend` PR #178 merged at
57
+ `b0eacdcbdb71be4c099b1b3839a11164b2d69232`; production deployment 41502378359
58
+ succeeded. Its `ONBOARDING_CONVERSION_AUDIT.md` tracks the full matrix. Live
59
+ authenticated identity and read-only Builder/Pro/Max handoff terms were verified.
60
+ Retention follow-up PR #179 also merged and deployment 41505976672 succeeded.
61
+ Stripe test access is unavailable. First-task/default-model guidance,
62
+ contextual exhaustion action, remaining legacy/docs naming and full measurement
63
+ segmentation still need work. No new end-to-end paid continuation or visual
64
+ screenshot acceptance is claimed.
65
+
66
+ The deployed website now precedes the native release. Publish a new immutable
67
+ beta and verify its exact public acquisition and upgrade. Preserve the user's
68
+ normal profile and Invest-With-Friends work during acceptance.
@@ -0,0 +1,60 @@
1
+ # Carthagent 1.1.0-beta.2 — release candidate
2
+
3
+ This candidate simplifies Cloud sign-in and keeps billing recovery connected to
4
+ the task already open in your terminal. Publication and installed acceptance are
5
+ pending; these notes do not establish availability on npm.
6
+
7
+ ## Cloud sign-in and first use
8
+
9
+ - Hosted models are named **Carthagent Cloud**. **Ship mode** remains the verified
10
+ coding workflow. Existing provider IDs and login aliases continue to work.
11
+ - Fresh Cloud sign-in goes directly through browser authorization. Legacy import
12
+ and upstream-key methods require `CARTHAGENT_ADVANCED_AUTH=1`.
13
+ - The browser identifies the account and terminal being connected. Device codes
14
+ last 20 minutes, with continuation-preserving sign-in recovery.
15
+ - Successful CLI sign-in suggests one small project task and points Cloud users
16
+ to `/account` for identity and actual available usage.
17
+
18
+ ## Review billing, then continue explicitly
19
+
20
+ - `/billing checkout builder`, `pro` or `max` shows authenticated plan terms
21
+ before opening checkout. Eligible new Builder customers pay **$1 for the first
22
+ month, then $10/month**; Pro starts at **$25/month**, Max at **$50/month**.
23
+ - The separate one-time starter usage credit is attempted on first terminal
24
+ authorization. Browser signup alone does not grant it.
25
+ - Existing card subscribers review Stripe's prorated amount before confirming a
26
+ change. The renewal anchor and consumed/reserved usage are preserved. Existing
27
+ grandfathered subscriptions retain their documented allowance policy.
28
+ - After payment, run `/billing refresh` in the existing session, or
29
+ `ctg billing refresh` outside the TUI. Then explicitly continue the task.
30
+ Checkout does not automatically submit a prompt or restart work.
31
+
32
+ ## Acceptance and limitations
33
+
34
+ Backend onboarding and retention reporting are deployed through PRs #178 and
35
+ #179. Native onboarding PR #238 is merged; first-task guidance is in PR #239.
36
+ The original onboarding suite passed 1,131 tests with five skips; corrected
37
+ native CI passed on Linux, macOS and Windows. These results precede final
38
+ beta.2 installed-package and public-acquisition acceptance.
39
+
40
+ Stripe test-mode access remains unavailable; real decline/SCA, tax/discount and
41
+ the full financial-transition matrix are not verified. No new live purchase or
42
+ end-to-end paid task continuation is claimed. Browser screenshot capture remains
43
+ unavailable. Local terminal acceptance initially showed event-stream timing
44
+ failures; the subsequent full installed-package run passed all 28 probes/scenarios
45
+ with the pinned beta.1 runtime, as did CI package acceptance. Final beta.2 draft
46
+ acceptance against its own manifest and assets is still required.
47
+
48
+ Verified-delivery rewards remain 25 coins, 100 XP and 1 km per eligible claimed
49
+ delivery, up to three per UTC day. Daily classifier assessments remain unrewarded.
50
+
51
+ ## Planned release channel
52
+
53
+ The candidate targets npm `next` and the beta updater channel. Stable `latest`
54
+ remains 1.0.7. Exact installation and update instructions will be verified after
55
+ the immutable release and its five runtime archives are published.
56
+
57
+ Runtime prebuild: [41505735348](https://github.com/B-icy/Carthagent/actions/runs/41505735348),
58
+ source `d999644028ed0c0dc70bcaf58060db4018278e48`. The runtime recipe remains
59
+ combined-v5 on upstream `843a6c514e606b436a7fe713422ace0a93ad3d61`, compatibility
60
+ 2.0.32. The final package must pin the generated archive and executable digests.
@@ -0,0 +1,47 @@
1
+ # Carthagent 1.1.0-beta.3 — release candidate
2
+
3
+ This candidate makes continuation failures actionable. Publication is pending;
4
+ these notes do not establish npm availability.
5
+
6
+ ## Continue the existing task
7
+
8
+ - An exhausted persistent execution budget now reports the reason and recovery
9
+ command instead of an unexplained interrupt.
10
+ - `--reset-budget` explicitly authorizes a fresh budget for a resumed session:
11
+
12
+ ```sh
13
+ ctg --print --continue --reset-budget "Continue the task"
14
+ ```
15
+
16
+ - Ordinary continuation and payment preserve the execution budget, including
17
+ elapsed idle time. Billing credit is separate. Interactive users can run
18
+ `/delivery-budget-reset` before continuing the existing task.
19
+ - CLI help consistently names Carthagent Cloud and documents `billing refresh`.
20
+
21
+ ## Acceptance evidence
22
+
23
+ The source fix passed 1,132 tests with five skips, builds and lint. PR #242 CI
24
+ passed Linux, macOS and Windows, browser and installed-package gates. One macOS
25
+ 22 service-stop identity timeout passed on retry; the separate push run passed.
26
+
27
+ The authorized existing Cloud account completed a real range-fix task. The same
28
+ session, profile and installed beta.2 runtime then continued with the source
29
+ launcher and explicit reset, adding a regression and passing all five project
30
+ tests independently. Final beta.3 installed acceptance remains required.
31
+
32
+ Backend PRs #180–181 are deployed: automatic payment-status checks, clearer
33
+ sign-in and refund guidance, direct Builder review, modern Stripe compatibility
34
+ and accurate scheduled-cancellation dates while retaining paid access.
35
+ Sandbox purchases, prorated upgrades, initial and upgrade SCA, declined-upgrade
36
+ recovery, renewal, failed renewal, automatic refunds and disputes were exercised
37
+ separately. These do not establish a single
38
+ useful-task → payment boundary → same-task continuation journey: the model task
39
+ used existing production credit and payments used an isolated sandbox backend.
40
+
41
+ ## Release channel
42
+
43
+ Targets npm `next`; stable `latest` remains 1.0.7. Runtime prebuild
44
+ [41537249365](https://github.com/B-icy/Carthagent/actions/runs/41537249365) uses
45
+ reviewed source `c11103f8b57c6b3254473a78d535a404bea9a84a` and the unchanged
46
+ combined-v5 runtime recipe. Final assets and manifest must pass immutable draft
47
+ acceptance before publication.
@@ -73,7 +73,7 @@ export class CloudClient {
73
73
  }
74
74
 
75
75
  async request(path, { method = 'GET', accessToken, body, signal } = {}) {
76
- if (typeof this.fetch !== 'function') throw new Error('Carthagent Ship requires fetch support.');
76
+ if (typeof this.fetch !== 'function') throw new Error('Carthagent Cloud requires fetch support.');
77
77
  const headers = { accept: 'application/json' };
78
78
  if (body !== undefined) headers['content-type'] = 'application/json';
79
79
  if (accessToken) headers.authorization = `Bearer ${accessToken}`;
@@ -128,6 +128,11 @@ export class CloudClient {
128
128
  return this.request('/v1/billing/checkout', { method: 'POST', accessToken, body: { successUrl, cancelUrl, priceId }, signal });
129
129
  }
130
130
 
131
+ /** @param {{ accessToken?: string, plan?: string, signal?: AbortSignal }} [input] */
132
+ quote({ accessToken, plan = 'builder', signal } = {}) {
133
+ return this.request(`/v1/billing/quote?plan=${encodeURIComponent(plan)}`, { accessToken, signal });
134
+ }
135
+
131
136
  portal({ accessToken, returnUrl, signal } = {}) {
132
137
  return this.request('/v1/billing/portal', { method: 'POST', accessToken, body: { returnUrl }, signal });
133
138
  }
@@ -184,10 +189,10 @@ function formatEpochSeconds(value) {
184
189
 
185
190
  export function cloudManagedUsageRecovery(value) {
186
191
  const text = String(value?.code || value?.message || value || '');
187
- if (/daily_limit_reached|managed daily usage limit/i.test(text)) return 'Managed daily usage is exhausted. It resets at 00:00 UTC. Use /account for headroom or /login for BYOK.';
188
- if (/monthly_allowance_exhausted|managed monthly allowance/i.test(text)) return 'Managed monthly usage is exhausted. Use /billing portal to manage the plan or /login for BYOK.';
189
- if (/subscription_inactive|managed subscription is not active/i.test(text)) return 'The managed subscription is inactive. Use /billing portal or /login for BYOK.';
190
- if (/insufficient_quota|insufficient.credit|credit (?:is )?exhausted/i.test(text)) return 'Carthagent Ship credit is exhausted. Use /billing checkout where available or /login for BYOK.';
192
+ if (/daily_limit_reached|managed daily usage limit/i.test(text)) return 'Cloud daily usage is exhausted. It resets at 00:00 UTC. Use /account for your plan and headroom, /billing portal to manage billing, or /login for your own provider (BYOK). Your task stays in this session.';
193
+ if (/monthly_allowance_exhausted|managed monthly allowance/i.test(text)) return 'Cloud monthly usage is exhausted. Use /account for your renewal date, /billing portal to manage billing, or /login for your own provider (BYOK). To review a different plan use /billing checkout pro or /billing checkout max. After payment use /billing refresh, then explicitly continue this task.';
194
+ if (/subscription_inactive|managed subscription is not active/i.test(text)) return 'Cloud access is awaiting payment or activation. Use /billing refresh to check, /billing portal to manage payment, or /login for your own provider.';
195
+ if (/insufficient_quota|insufficient.credit|credit (?:is )?exhausted/i.test(text)) return 'Carthagent Cloud usage credit is exhausted. Use /billing checkout builder to review your eligible price (new Builder customers: $1 first month, then $10/month), or /login for your own provider. After payment use /billing refresh, then explicitly continue this task.';
191
196
  return null;
192
197
  }
193
198
 
@@ -256,9 +261,10 @@ export function formatCloudAccount(account, { usage } = {}) {
256
261
  const managed = account?.managedUsage;
257
262
  const planName = managed?.planName || 'plan';
258
263
  const lines = [
259
- `Carthagent Ship account ${account?.accountId || 'unknown'}`,
264
+ `Carthagent Cloud account ${account?.accountId || 'unknown'}`,
260
265
  `Credit: ${formatNanoUsd(account?.balance?.availableNanoUsd)} available`,
261
- `Plan: ${subscription.status && subscription.status !== 'none' ? `${planName} (${subscription.status})` : 'Free'}`,
266
+ `Plan: ${subscription.status && subscription.status !== 'none' ? `${planName} (${subscription.status})` : 'No paid plan'}`,
267
+ ...(account?.email ? [`Signed in as: ${account.email}`] : []),
262
268
  ];
263
269
  const subscriptionEnd = formatEpochSeconds(subscription.current_period_end);
264
270
  if (subscriptionEnd) lines.push(`Renews/ends: ${subscriptionEnd}`);
@@ -100,8 +100,9 @@ function errorText(error) {
100
100
  function headlessFailureMessage(latest, headlessError) {
101
101
  const raw = errorText(latest?.error ?? latest?.session?.error ?? lastAssistantError(latest?.messages) ?? headlessError);
102
102
  const outcome = latest?.outcome ?? latest?.session?.outcome;
103
+ if (['elapsed-time', 'tool-calls', 'repair-rounds'].includes(latest?.budgetReason)) return `Session execution budget exhausted: ${latest.budgetReason}. Billing refresh does not reset execution limits. To authorize a fresh budget, resume this session with --reset-budget (for example: ctg --print --continue --reset-budget "Continue the task"), or run /delivery-budget-reset in the existing terminal session before continuing.`;
103
104
  if (/ModelNotSelectedError|No model is available|no provider|provider.*not (connected|selected|configured)|not authenticated/i.test(raw ?? '')) {
104
- return 'No AI provider connected. Run `ctg login` to connect one (Carthagent Ship is recommended), or set an API key env var (e.g. ANTHROPIC_API_KEY), then retry. Run `ctg native --list-models` to see available models.';
105
+ return 'No AI provider connected. Run `ctg login` to connect one (Carthagent Cloud is recommended), or set an API key env var (e.g. ANTHROPIC_API_KEY), then retry. Run `ctg native --list-models` to see available models.';
105
106
  }
106
107
  if (raw) return `Native run failed: ${raw}`;
107
108
  if (latest?.reason || latest?.budgetReason) return `Native run failed: ${latest.reason ?? latest.budgetReason}`;
@@ -311,10 +312,10 @@ function shipLoginGuidance(error) {
311
312
  const message = String(error?.data?.message || error?.message || '');
312
313
  const type = String(error?.type || error?.data?.type || '');
313
314
  if (type === 'ship.unauthenticated' || type === 'ship.not-connected') {
314
- return 'Carthagent Ship is not connected. Run `ctg login` and choose Carthagent Ship, then retry.';
315
+ return 'Carthagent Cloud is not connected. Run `ctg login` and choose Carthagent Cloud, then retry.';
315
316
  }
316
- if (/active Carthagent Ship OAuth connection is required|not authenticated|unauthorized/i.test(message)) {
317
- return 'Carthagent Ship is not connected. Run `ctg login` and choose Carthagent Ship, then retry.';
317
+ if (/active Carthagent (?:Ship|Cloud) OAuth connection is required|not authenticated|unauthorized/i.test(message)) {
318
+ return 'Carthagent Cloud is not connected. Run `ctg login` and choose Carthagent Cloud, then retry.';
318
319
  }
319
320
  }
320
321
 
@@ -559,6 +560,9 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
559
560
  options.session = await latestSessionForWorkspace(client, cwd);
560
561
  if (!options.session) throw new Error('No native Carthagent session exists for this workspace to continue.');
561
562
  }
563
+ if (options.resetBudget && !options.session && !options.continue && !options.resume) {
564
+ throw new Error('--reset-budget requires --session, --continue or --resume.');
565
+ }
562
566
  eventController = new AbortController();
563
567
  const nativeEvents = [];
564
568
  let eventFailure;
@@ -600,6 +604,9 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
600
604
  const definitions = native.rpcDefinitions ?? await nativeRpcDefinitions();
601
605
  await client.rpc(definitions.SessionControls).framing({ sessionID, enabled: options.autoFraming }, { location: { directory: cwd }, signal: signal.controller.signal });
602
606
  }
607
+ if (options.resetBudget && sessionID) {
608
+ await client.session.command({ sessionID, name: 'delivery-budget-reset', text: '' }, { location: { directory: cwd }, signal: signal.controller.signal });
609
+ }
603
610
  if (framedPrompt) {
604
611
  if (!sessionID) {
605
612
  const created = await headlessApi.createSession(service, cwd, { body: {
@@ -644,6 +651,11 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
644
651
  const definitions = native.rpcDefinitions ?? await nativeRpcDefinitions();
645
652
  await client.rpc(definitions.SessionControls).framing({ sessionID: requestedSession, enabled: options.autoFraming }, { location: { directory: cwd }, signal: signal.controller.signal });
646
653
  }
654
+ if (requestedSession && options.resetBudget && !signal.controller.signal.aborted) {
655
+ const resumed = await client.session.get({ sessionID: requestedSession }, { signal: signal.controller.signal });
656
+ assertSessionWorkspace(resumed, cwd);
657
+ await client.session.command({ sessionID: requestedSession, name: 'delivery-budget-reset', text: '' }, { location: { directory: cwd }, signal: signal.controller.signal });
658
+ }
647
659
  try {
648
660
  result = await headlessApi.runHeadless(service, cwd, framedPrompt || prompt || '', {
649
661
  sessionID: options.session,
@@ -658,7 +670,16 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
658
670
  headlessError = error;
659
671
  result = { session: { id: options.session }, messages: [], outcome: signal.controller.signal.aborted ? 'interrupted' : 'failed', terminal: true, error: error.message };
660
672
  }
661
- const latest = headlessError ? result : await reconcileHeadlessResult(result, { client, signal: signal.controller.signal });
673
+ let latest = headlessError ? result : await reconcileHeadlessResult(result, { client, signal: signal.controller.signal });
674
+ if (latest.outcome === 'interrupted' && !latest.error && !latest.session?.error && !lastAssistantError(latest.messages) && latest.session?.id && typeof client.rpc === 'function' && !signal.controller.signal.aborted) {
675
+ // An interrupt may carry no error in the host projection. Read the
676
+ // authoritative persistent budget without restarting the task.
677
+ try {
678
+ const definitions = native.rpcDefinitions ?? await nativeRpcDefinitions();
679
+ const budget = await client.rpc(definitions.Carthagent).budgetStatus({ sessionID: latest.session.id }, { location: { directory: cwd }, signal: AbortSignal.any([signal.controller.signal, AbortSignal.timeout(2000)]) });
680
+ if (budget?.stopped) latest = { ...latest, budgetReason: budget.stopped, error: latest.error ?? headlessFailureMessage({ budgetReason: budget.stopped }) };
681
+ } catch { /* Keep the original failure when budget status is unavailable. */ }
682
+ }
662
683
  eventController.abort();
663
684
  await eventReader;
664
685
  if (eventFailure) throw new Error(`Native Carthagent event stream failed: ${eventFailure.message}`, { cause: eventFailure });
@@ -720,7 +741,7 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
720
741
  if (!rows.length) {
721
742
  io.error(term
722
743
  ? `No models match "${query.trim()}". Run \`ctg native --list-models\` to see every available model, or \`ctg login\` to connect a provider.`
723
- : 'No AI provider connected, so no models are available. Run `ctg login` to connect one (Carthagent Ship is recommended), or set an API key env var (e.g. ANTHROPIC_API_KEY), then run `ctg native --list-models` again.');
744
+ : 'No AI provider connected, so no models are available. Run `ctg login` to connect one (Carthagent Cloud is recommended), or set an API key env var (e.g. ANTHROPIC_API_KEY), then run `ctg native --list-models` again.');
724
745
  }
725
746
  return rows.length;
726
747
  }
@@ -741,6 +762,12 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
741
762
  let integration;
742
763
  let attempt;
743
764
  let completed = false;
765
+ const connected = () => {
766
+ completed = true;
767
+ io.log(`Connected to ${integration.name}.`);
768
+ io.log('Run ctg in your project. Start small: “Find one failing test, explain the cause, and fix it with a regression test.”');
769
+ if (integration.id === 'experiential-labs') io.log('Use /account to check the connected account and available usage. Starter usage is granted only when eligible.');
770
+ };
744
771
  try {
745
772
  ({ client } = await serviceClient());
746
773
  location = { directory: cwd };
@@ -809,7 +836,7 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
809
836
  }
810
837
  if (method.type === 'external') {
811
838
  await client.integration.connect.external({ integrationID: integration.id, methodID: method.id, answer, location }, { signal: controller.signal });
812
- io.log(`Connected to ${integration.name}.`);
839
+ connected();
813
840
  return 0;
814
841
  }
815
842
  if (method.type === 'key') {
@@ -817,8 +844,7 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
817
844
  const key = secretField ? answer[secretField.key] : await questionMasked(stdin, stdout, `${integration.name} API key`, controller.signal);
818
845
  if (typeof key !== 'string' || !key.trim()) throw new Error('An API key is required.');
819
846
  await client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal: controller.signal });
820
- io.log(`Connected to ${integration.name}.`);
821
- completed = true;
847
+ connected();
822
848
  return 0;
823
849
  }
824
850
  if (method.type !== 'oauth') throw new Error(`Authentication method ${method.type} is not supported by native CLI login.`);
@@ -838,15 +864,14 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
838
864
  code = await questionWithAbort(prompt, 'Paste the authorization code: ', controller.signal);
839
865
  if (!code.trim()) throw new Error('Authorization code is required.');
840
866
  await client.integration.oauth.complete({ integrationID: integration.id, attemptID: attempt.attemptID, code: code.trim(), location }, { signal: controller.signal });
841
- io.log(`Connected to ${integration.name}.`);
842
- completed = true;
867
+ connected();
843
868
  return 0;
844
869
  }
845
870
  io.log('Waiting for authorization… (Ctrl+C to cancel)');
846
871
  while (!controller.signal.aborted && Date.now() < attempt.time.expires) {
847
872
  const response = await client.integration.oauth.status({ integrationID: integration.id, attemptID: attempt.attemptID, location }, { signal: controller.signal });
848
873
  const status = response.data;
849
- if (status.status === 'complete') { io.log(`Connected to ${integration.name}.`); completed = true; return 0; }
874
+ if (status.status === 'complete') { connected(); return 0; }
850
875
  if (status.status === 'failed') throw new Error(status.message || 'Authorization failed.');
851
876
  if (status.status === 'expired') throw new Error('Authorization expired.');
852
877
  await new Promise((resolve, reject) => {
@@ -929,7 +954,7 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
929
954
  const auth = await shipCredential();
930
955
  let result;
931
956
  try { result = (await auth.rpc.shipAccount({}, { location: { directory: cwd } })).account; }
932
- catch (error) { throw new Error(shipLoginGuidance(error) || `Carthagent Ship: ${error.message}`); }
957
+ catch (error) { throw new Error(shipLoginGuidance(error) || `Carthagent Cloud: ${error.message}`); }
933
958
  const cloud = await cloudClient('');
934
959
  if (args.includes('--json')) io.log(JSON.stringify(result));
935
960
  else {
@@ -942,13 +967,26 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
942
967
  return 0;
943
968
  }
944
969
 
945
- async function billing({ action }) {
970
+ async function billing({ action, plan }) {
971
+ if (action === 'refresh') {
972
+ const result = await account();
973
+ io.log('Return to your existing session. When usage is available, explicitly continue the task.');
974
+ return result;
975
+ }
946
976
  const auth = await shipCredential();
947
977
  let result;
948
- try { result = await auth.rpc.shipBilling({ action }, { location: { directory: cwd } }); }
949
- catch (error) { throw new Error(shipLoginGuidance(error) || `Carthagent Ship: ${error.message}`); }
978
+ try {
979
+ if (action === 'checkout') {
980
+ const { quote } = await auth.rpc.shipBilling({ action: 'quote', ...(plan ? { plan } : {}) }, { location: { directory: cwd } });
981
+ io.log(quote.terms);
982
+ if (quote.changingPlan) io.log('Review the exact proration in your browser before confirming the change.');
983
+ }
984
+ result = await auth.rpc.shipBilling({ action, ...(plan ? { plan } : {}) }, { location: { directory: cwd } });
985
+ }
986
+ catch (error) { throw new Error(shipLoginGuidance(error) || `Carthagent Cloud: ${error.message}`); }
950
987
  const opened = await openBrowserImpl(result.url).catch(() => false);
951
988
  io.log(`${opened ? 'Opened' : 'Open'} ${result.url}`);
989
+ io.log('After payment, run ctg billing refresh. Return to your existing session and explicitly continue the task.');
952
990
  return 0;
953
991
  }
954
992
 
@@ -956,11 +994,11 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
956
994
  const auth = await shipCredential();
957
995
  let result;
958
996
  try { result = await auth.rpc.shipLogout({ ...(sessionID ? { session: sessionID } : {}) }, { location: { directory: cwd } }); }
959
- catch (error) { throw new Error(shipLoginGuidance(error) || `Carthagent Ship logout: ${error.message}`); }
997
+ catch (error) { throw new Error(shipLoginGuidance(error) || `Carthagent Cloud logout: ${error.message}`); }
960
998
  if (!sessionID && result.credentialID) {
961
999
  await auth.client.credential.remove({ credentialID: result.credentialID });
962
1000
  }
963
- io.log(sessionID ? `Revoked Ship session ${sessionID}.` : 'Revoked and removed the current Carthagent Ship session.');
1001
+ io.log(sessionID ? `Revoked Cloud session ${sessionID}.` : 'Revoked and removed the current Carthagent Cloud session.');
964
1002
  return 0;
965
1003
  }
966
1004
 
@@ -26,6 +26,7 @@ const BOOL_FLAGS = new Map([
26
26
  ['--protect-existing', ['protectExisting', true]], ['--jev', ['jev', true]], ['--no-jev', ['jev', false]],
27
27
  ['--print', ['print', true]], ['--demo', ['demo', true]], ['-c', ['continue', true]],
28
28
  ['--continue', ['continue', true]], ['-r', ['resume', true]], ['--resume', ['resume', true]],
29
+ ['--reset-budget', ['resetBudget', true]],
29
30
  ]);
30
31
 
31
32
  const aliases = new Map([
@@ -84,9 +85,9 @@ export function cliHelp(version = '') {
84
85
  \x1b[32mhash\x1b[0m, \x1b[32mfingerprint\x1b[0m Calculate workspace SHA-256 fingerprint
85
86
  \x1b[32mvalidate\x1b[0m <file.json> Validate acceptance contract
86
87
  \x1b[32mlogin\x1b[0m, \x1b[32mauth\x1b[0m Connect an AI provider
87
- \x1b[32maccount\x1b[0m Show Ship credit, plan, grants and CLI sessions
88
- \x1b[32mbilling\x1b[0m [checkout|portal] Open Ship billing
89
- \x1b[32mlogout-cloud\x1b[0m [session] Revoke the current or named Ship session
88
+ \x1b[32maccount\x1b[0m Show Cloud credit, plan, grants and CLI sessions
89
+ \x1b[32mbilling\x1b[0m [checkout|portal|refresh] Open or refresh Cloud billing
90
+ \x1b[32mlogout-cloud\x1b[0m [session] Revoke the current or named Cloud session
90
91
  \x1b[32marchive\x1b[0m [list|search|read|export] Read-only legacy session archive
91
92
  \x1b[32mreview\x1b[0m Configure self-review or review a PR
92
93
  \x1b[32mserve\x1b[0m, \x1b[32mweb\x1b[0m, \x1b[32mstart\x1b[0m Start the web dashboard
@@ -110,6 +111,8 @@ export function cliHelp(version = '') {
110
111
  --max-tools <n> Session tool-call budget (0 disables)
111
112
  --max-seconds <n> Session elapsed-time budget (0 disables)
112
113
  --max-repairs <n> Automatic-repair budget (0 disables)
114
+ --reset-budget Explicitly reset a resumed session's execution budget
115
+ Budgets persist across continuation, including idle time.
113
116
  --protect-existing Preserve files present at session start
114
117
  --rewrite-cap <n> Whole-file writes per path (0 disables)
115
118
  --tool-output-cap <n> Bound text output per tool result
@@ -189,15 +192,16 @@ export async function runOpenCodeCli({ argv = [], runtime, io = console, stdoutI
189
192
  return fail(error.message, 1, io);
190
193
  }
191
194
  }
192
- if (normalized === 'logout') return fail('Use `logout-cloud` to revoke a Carthagent Ship session; provider credentials are managed by the native runtime.', 1, io);
195
+ if (normalized === 'logout') return fail('Use `logout-cloud` to revoke a Carthagent Cloud session; provider credentials are managed by the native runtime.', 1, io);
193
196
  if (normalized === 'account' || normalized === 'cloud') {
194
197
  try { return Number((await ensureMethod(runtime, 'account')({ args: rest })) ?? 0); }
195
198
  catch (error) { return fail(error.message, 1, io); }
196
199
  }
197
200
  if (normalized === 'billing') {
198
201
  const action = rest[0] || 'checkout';
199
- if (!['checkout', 'portal'].includes(action)) return fail('Usage: ctg billing [checkout|portal]', 1, io);
200
- try { return Number((await ensureMethod(runtime, 'billing')({ action })) ?? 0); }
202
+ const plan = rest[1];
203
+ if (!['checkout', 'portal', 'refresh'].includes(action) || (plan && !['builder', 'pro', 'max'].includes(plan))) return fail('Usage: ctg billing [checkout [builder|pro|max]|portal|refresh]', 1, io);
204
+ try { return Number((await ensureMethod(runtime, 'billing')({ action, ...(plan ? { plan } : {}) })) ?? 0); }
201
205
  catch (error) { return fail(error.message, 1, io); }
202
206
  }
203
207
  if (normalized === 'logout-cloud') {
@@ -1,5 +1,5 @@
1
1
  export const NATIVE_LOGIN_PROVIDERS = Object.freeze([
2
- Object.freeze({ id: 'experiential-labs', name: 'Carthagent Ship', aliases: ['ship'], oauth: 'carthagent-device' }),
2
+ Object.freeze({ id: 'experiential-labs', name: 'Carthagent Cloud', aliases: ['cloud', 'ship', 'carthagent ship'], oauth: 'carthagent-device' }),
3
3
  Object.freeze({ id: 'anthropic', name: 'Claude / Anthropic', aliases: ['claude', 'claudecode'] }),
4
4
  Object.freeze({ id: 'xai', name: 'xAI / Grok', aliases: ['grok', 'xai-code'], oauth: 'device' }),
5
5
  Object.freeze({ id: 'openai', name: 'Codex / OpenAI', aliases: ['codex', 'openai-codex'], oauth: 'chatgpt-token-sharing' }),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "releaseVersion": "1.1.0-beta.1",
3
+ "releaseVersion": "1.1.0-beta.3",
4
4
  "compatibilityVersion": "2.0.32",
5
5
  "runtimeVersion": "0.0.0-ctg.2.0.32",
6
6
  "sourceRevision": "843a6c514e606b436a7fe713422ace0a93ad3d61",
@@ -9,28 +9,28 @@
9
9
  "assets": {
10
10
  "linux-x64": {
11
11
  "name": "opencode-linux-x64.tar.gz",
12
- "archiveSha256": "e0abac9f7e9d99f1b3b88d11e1efb7ce1de08b7e7474de33c1279ec1797d1aae",
13
- "executableSha256": "f6dd022de1854d67056b1e8b039196dcb69f887e5fe5dc8839e75d40debb9cc1"
12
+ "archiveSha256": "ab95bd99fc2bf52f867927a817dde1d3882122b80f85ef85cf416733afa62483",
13
+ "executableSha256": "f9ad13d8aad76bf1d16334712f0060b886757f5cdabddacce42f9805d4482c1c"
14
14
  },
15
15
  "linux-arm64": {
16
16
  "name": "opencode-linux-arm64.tar.gz",
17
- "archiveSha256": "cf2e5b020b100801e817d650b8f57ba82118036ffb4f0296b29af4a9ce9c5afd",
18
- "executableSha256": "e494c40d16934576338ea8ddd7ad5a4935fc0dc8e1efcebd78eb8074af2a795f"
17
+ "archiveSha256": "3869dd9398693afffe9f438192fbb12061b66a211993018379fb6870d2012bd2",
18
+ "executableSha256": "0c302d55bba608d83c42f54b769ec3994421d3dcb16ea5d2a9455dcd91f884a1"
19
19
  },
20
20
  "darwin-x64": {
21
21
  "name": "opencode-darwin-x64.tar.gz",
22
- "archiveSha256": "ed5f5cfa0edeca391ecaf7789347916c3d1779b9b1c8afb6feafb6a1fa82e95c",
23
- "executableSha256": "eddb5ac76384986a05cd86aecf4abe6fb76b11f0370775a515b46e07a6875e9c"
22
+ "archiveSha256": "b20111d8fd2368ac9bde7f90b12835a465b8c92da3db1f3a35671860a325eb27",
23
+ "executableSha256": "23f7a845e75592efa11dff407e147287584eae7f63fca1a9c4ccbe9558656bc3"
24
24
  },
25
25
  "darwin-arm64": {
26
26
  "name": "opencode-darwin-arm64.tar.gz",
27
- "archiveSha256": "f3ee781431819b5d1a5c35905d88d56cef0ead5b96b7007da6e046e531a6a87d",
28
- "executableSha256": "3afa14710324db7db3598d51c7616405615d78c3804fd77faf82b424747561fa"
27
+ "archiveSha256": "eb65c635c8cf19f0830b56b2d8087387148edaf44cf821c881b72388c9c185fd",
28
+ "executableSha256": "d10a7b549748a131c578ed8cd041aeb652f1ae9c79c5f3912a731f2a69218736"
29
29
  },
30
30
  "windows-x64": {
31
31
  "name": "opencode-windows-x64.tar.gz",
32
- "archiveSha256": "8a433fa26fccc04e010009f9ffe368c322d867a3030cb3bb831bca3e23c328f1",
33
- "executableSha256": "139325dd6b5d9535b0259dec236b6493671909c6f61ed9ae7772b80b41332037"
32
+ "archiveSha256": "a0309761781f57b5ea9f329d41f2e977391949addc9baaca6b85025780249d3c",
33
+ "executableSha256": "91eac290633313c7fce4a426ef265f193e995b285746df6a2a6010f4727fe813"
34
34
  }
35
35
  }
36
36
  }
@@ -1,5 +1,5 @@
1
1
  export const CARTHAGENT_SHIP_PROVIDER_ID = 'experiential-labs';
2
- export const CARTHAGENT_SHIP_PROVIDER_NAME = 'Carthagent Ship';
2
+ export const CARTHAGENT_SHIP_PROVIDER_NAME = 'Carthagent Cloud';
3
3
 
4
4
  export function providerDisplayName(providerId) {
5
5
  return providerId === CARTHAGENT_SHIP_PROVIDER_ID ? CARTHAGENT_SHIP_PROVIDER_NAME : String(providerId || '');
@@ -30,7 +30,7 @@ export function providerForModel(model, list) {
30
30
  export function modelMatchesQuery(model, query) {
31
31
  const tokens = String(query || '').toLowerCase().match(/[\p{L}\p{N}]+/gu) || [];
32
32
  if (!tokens.length) return true;
33
- const searchable = [model?.provider, providerDisplayName(model?.provider), model?.id, model?.full, model?.name]
33
+ const searchable = [model?.provider, providerDisplayName(model?.provider), model?.provider === 'experiential-labs' ? 'Carthagent Ship' : '', model?.id, model?.full, model?.name]
34
34
  .filter(Boolean)
35
35
  .join(' ')
36
36
  .toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carthagent",
3
- "version": "1.1.0-beta.1",
3
+ "version": "1.1.0-beta.3",
4
4
  "workspaces": [
5
5
  "plugins/carthagent"
6
6
  ],
@@ -95,8 +95,8 @@ export const Carthagent = Rpc.define({
95
95
  output: { type: 'object', properties: { usage: { type: 'object', additionalProperties: true } }, required: ['usage'], additionalProperties: false },
96
96
  },
97
97
  shipBilling: {
98
- input: { type: 'object', properties: { action: { type: 'string', enum: ['checkout', 'portal'] } }, required: ['action'], additionalProperties: false },
99
- output: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'], additionalProperties: false },
98
+ input: { type: 'object', properties: { action: { type: 'string', enum: ['checkout', 'portal', 'quote'] }, plan: { type: 'string', enum: ['builder', 'pro', 'max'] } }, required: ['action'], additionalProperties: false },
99
+ output: { type: 'object', properties: { url: { type: 'string' }, quote: { type: 'object', additionalProperties: true } }, additionalProperties: false },
100
100
  errors: { unauthenticated: { type: 'object', properties: {}, additionalProperties: false } },
101
101
  },
102
102
  shipLogout: {
@@ -120,19 +120,18 @@ function oauthMethod({ env, fetchImpl, sleep, now, openBrowserImpl, agentDir, st
120
120
  return {
121
121
  integrationID: IntegrationSchema.ID.make(SHIP_INTEGRATION_ID),
122
122
  method: {
123
- id: OAUTH_METHOD, type: 'oauth' as const, label: 'Sign in with Carthagent Ship',
124
- form: [{ type: 'boolean' as const, key: 'importLegacy', title: 'Import a legacy Ship credential instead of signing in?',
125
- description: 'Leave disabled for fresh browser approval and updated account permissions.', default: false }] satisfies Form.Fields,
123
+ id: OAUTH_METHOD, type: 'oauth' as const, label: 'Continue with Carthagent Cloud',
124
+ ...(env.CARTHAGENT_ADVANCED_AUTH === '1' ? { form: [{ type: 'boolean' as const, key: 'importLegacy', title: 'Import a legacy Cloud credential?', default: false }] satisfies Form.Fields } : {}),
126
125
  },
127
126
  authorize: async (answer: Form.Answer) => {
128
127
  // The legacy file intentionally survives migration. Never let its presence
129
128
  // short-circuit an explicit sign-in or recovery from revoked/old scopes.
130
- const legacy = answer.importLegacy === true ? await legacyOAuthForOpenCode({ agentDir }) : undefined;
129
+ const legacy = env.CARTHAGENT_ADVANCED_AUTH === '1' && answer.importLegacy === true ? await legacyOAuthForOpenCode({ agentDir }) : undefined;
131
130
  if (legacy) {
132
131
  return {
133
132
  mode: 'auto' as const,
134
133
  url: 'https://api.carthagent.xyz',
135
- instructions: 'A legacy Carthagent Ship credential was found and will be migrated into OpenCode integration storage.',
134
+ instructions: 'A legacy Carthagent Cloud credential was found and will be migrated into native integration storage.',
136
135
  callback: Promise.resolve({
137
136
  type: 'oauth' as const,
138
137
  methodID: IntegrationSchema.MethodID.make(OAUTH_METHOD),
@@ -147,7 +146,7 @@ function oauthMethod({ env, fetchImpl, sleep, now, openBrowserImpl, agentDir, st
147
146
  Promise.resolve(openBrowserImpl(url)).catch(() => false);
148
147
  return {
149
148
  mode: 'auto' as const, url,
150
- instructions: `Approve device code ${response.userCode} in your browser.`,
149
+ instructions: `Sign in and connect this terminal using code ${response.userCode}. Check the account shown in your browser, then return here. If the code expires, start login again.`,
151
150
  expiresAt: now() + Number(response.expiresIn || 600) * 1000,
152
151
  callback,
153
152
  };
@@ -169,13 +168,15 @@ function oauthMethod({ env, fetchImpl, sleep, now, openBrowserImpl, agentDir, st
169
168
  });
170
169
  },
171
170
  ),
172
- label: () => 'Carthagent Ship',
171
+ label: () => 'Carthagent Cloud',
173
172
  };
174
173
  }
175
174
 
176
175
  function integrationDefinitions(editor: Parameters<Context['integration']['transform']>[0] extends (editor: infer E) => void ? E : never, options: Parameters<typeof oauthMethod>[0]) {
177
176
  editor.method.update(oauthMethod(options));
178
- editor.method.update({ integrationID: IntegrationSchema.ID.make(SHIP_INTEGRATION_ID), method: { type: 'key', label: 'Use an Experiential API key' } });
177
+ if (options.env.CARTHAGENT_ADVANCED_AUTH === '1') {
178
+ editor.method.update({ integrationID: IntegrationSchema.ID.make(SHIP_INTEGRATION_ID), method: { type: 'key', label: 'Advanced: use an Experiential API key' } });
179
+ }
179
180
  editor.method.update({ integrationID: IntegrationSchema.ID.make(SHIP_INTEGRATION_ID), method: { type: 'env', names: [EXPERIENTIAL_API_KEY_ENV] } });
180
181
  editor.update(SHIP_INTEGRATION_ID, integration => { integration.name = EXPERIENTIAL_PROVIDER_NAME; });
181
182
  }
@@ -197,7 +198,7 @@ async function loadShipModels({ ctx, fetchImpl, env, baseURL, signal }: {
197
198
  const response = await fetchImpl(`${managed ? cloudGatewayUrl(env) : baseURL}/models`, {
198
199
  headers: { authorization: `Bearer ${key}` }, signal: signalWithTimeout(signal, DISCOVERY_TIMEOUT),
199
200
  });
200
- if (!response.ok) throw new Error(`Carthagent Ship model discovery failed (${response.status}).`);
201
+ if (!response.ok) throw new Error(`Carthagent Cloud model discovery failed (${response.status}).`);
201
202
  const models = parseExperientialModels(await response.json()).slice(0, PROFILE_CAP);
202
203
  if (connection && managed) return { connection, models, managed };
203
204
  return { connection, models, managed };
@@ -4,7 +4,7 @@ import type { ConnectionInfo } from '@opencode/client';
4
4
 
5
5
  type ShipContext = Pick<Context, 'integration'>;
6
6
  type ActiveCredential = Extract<ConnectionInfo, { type: 'credential' }>;
7
- type CloudClientPort = Pick<CloudClient, 'account' | 'usage' | 'checkout' | 'portal' | 'revokeCurrent' | 'revokeSession'>;
7
+ type CloudClientPort = Pick<CloudClient, 'account' | 'usage' | 'quote' | 'checkout' | 'portal' | 'revokeCurrent' | 'revokeSession'>;
8
8
 
9
9
  /** Create profile-scoped Ship RPC handlers; injection keeps cloud behavior testable offline. */
10
10
  export function createShipRpcHandlers(ctx: ShipContext, options: {
@@ -15,10 +15,10 @@ export function createShipRpcHandlers(ctx: ShipContext, options: {
15
15
  const cloud = options.cloudClient ?? new CloudClient({ baseUrl: options.baseUrl ?? cloudControlUrl() });
16
16
  async function access() {
17
17
  const active = await ctx.integration.connection.active('experiential-labs');
18
- if (!active || active.type !== 'credential' || active.method !== 'oauth') throw new Error('An active Carthagent Ship OAuth connection is required.');
18
+ if (!active || active.type !== 'credential' || active.method !== 'oauth') throw new Error('An active Carthagent Cloud OAuth connection is required.');
19
19
  const credential = await ctx.integration.connection.resolve(active);
20
20
  if (credential?.type !== 'oauth' || credential.methodID !== 'carthagent-device' || !credential.access) {
21
- throw new Error('An active Carthagent Ship OAuth connection is required.');
21
+ throw new Error('An active Carthagent Cloud OAuth connection is required.');
22
22
  }
23
23
  return { active: active as ActiveCredential, token: credential.access };
24
24
  }
@@ -27,27 +27,29 @@ export function createShipRpcHandlers(ctx: ShipContext, options: {
27
27
  call.signal.throwIfAborted();
28
28
  let token: string;
29
29
  try { ({ token } = await access()); }
30
- catch { return call.error('unauthenticated', 'An active Carthagent Ship OAuth connection is required.', {}); }
30
+ catch { return call.error('unauthenticated', 'An active Carthagent Cloud OAuth connection is required.', {}); }
31
31
  return { account: await cloud.account({ accessToken: token, signal: call.signal }) };
32
32
  },
33
33
  shipUsage: async (input: unknown, call: { signal: AbortSignal }) => {
34
34
  call.signal.throwIfAborted();
35
35
  const { token } = await access();
36
36
  const requested = (input as { limit?: number }).limit ?? 50;
37
- if (!Number.isSafeInteger(requested) || requested < 1 || requested > 200) throw new Error('Ship usage limit must be between 1 and 200.');
37
+ if (!Number.isSafeInteger(requested) || requested < 1 || requested > 200) throw new Error('Cloud usage limit must be between 1 and 200.');
38
38
  return { usage: await cloud.usage({ accessToken: token, limit: requested, signal: call.signal } as Parameters<CloudClient['usage']>[0]) };
39
39
  },
40
40
  shipBilling: async (input: unknown, call: import('@opencode/plugin/promise/rpc').RpcCallContext<typeof import('../rpc').Carthagent.methods.shipBilling>) => {
41
41
  call.signal.throwIfAborted();
42
42
  let token: string;
43
43
  try { ({ token } = await access()); }
44
- catch { return call.error('unauthenticated', 'An active Carthagent Ship OAuth connection is required.', {}); }
45
- const action = (input as { action: 'checkout' | 'portal' }).action;
46
- if (action !== 'checkout' && action !== 'portal') throw new Error('Unsupported Ship billing action.');
44
+ catch { return call.error('unauthenticated', 'An active Carthagent Cloud OAuth connection is required.', {}); }
45
+ const { action, plan = 'builder' } = input as { action: 'checkout' | 'portal' | 'quote'; plan?: string };
46
+ if (!['checkout', 'portal', 'quote'].includes(action)) throw new Error('Unsupported Cloud billing action.');
47
+ if (!['builder', 'pro', 'max'].includes(plan)) throw new Error('Choose Builder, Pro, or Max.');
48
+ if (action === 'quote') return { quote: await cloud.quote({ accessToken: token, plan, signal: call.signal }) };
47
49
  const returnUrl = `${options.cloudUrl ?? cloudControlUrl()}/account`;
48
50
  const result = action === 'portal'
49
51
  ? await cloud.portal({ accessToken: token, returnUrl, signal: call.signal })
50
- : await cloud.checkout({ accessToken: token, successUrl: returnUrl, cancelUrl: returnUrl, signal: call.signal });
52
+ : await cloud.checkout({ accessToken: token, priceId: plan, successUrl: `${options.cloudUrl ?? cloudControlUrl()}/v1/browser/billing/complete`, cancelUrl: `${options.cloudUrl ?? cloudControlUrl()}/v1/browser/billing/cancelled?plan=${plan}`, signal: call.signal });
51
53
  return { url: result.url };
52
54
  },
53
55
  shipLogout: async (input: unknown, call: import('@opencode/plugin/promise/rpc').RpcCallContext<typeof import('../rpc').Carthagent.methods.shipLogout>) => {
@@ -55,7 +57,7 @@ export function createShipRpcHandlers(ctx: ShipContext, options: {
55
57
  let active: ActiveCredential;
56
58
  let token: string;
57
59
  try { ({ active, token } = await access()); }
58
- catch { return call.error('unauthenticated', 'An active Carthagent Ship OAuth connection is required.', {}); }
60
+ catch { return call.error('unauthenticated', 'An active Carthagent Cloud OAuth connection is required.', {}); }
59
61
  const session = (input as { session?: string }).session;
60
62
  if (session) {
61
63
  await cloud.revokeSession({ accessToken: token, sessionId: session, signal: call.signal });
@@ -308,7 +308,7 @@ export default Plugin.define({
308
308
  }
309
309
  case "account": {
310
310
  const result = await shipClient(context, location).account() as { account: Record<string, unknown> };
311
- await context.ui.dialog.alert({ title: "Carthagent Ship account", message: cloudAccountText(result.account) });
311
+ await context.ui.dialog.alert({ title: "Carthagent Cloud account", message: cloudAccountText(result.account) });
312
312
  return;
313
313
  }
314
314
  case "usage": {
@@ -316,24 +316,34 @@ export default Plugin.define({
316
316
  shipClient(context, location).account() as Promise<{ account: Record<string, unknown> }>,
317
317
  shipClient(context, location).usage({ limit: 50 }) as Promise<{ usage: Record<string, unknown> }>,
318
318
  ]);
319
- await context.ui.dialog.alert({ title: "Carthagent Ship usage", message: cloudUsageText(usageResult.usage, accountResult.account) });
319
+ await context.ui.dialog.alert({ title: "Carthagent Cloud usage", message: cloudUsageText(usageResult.usage, accountResult.account) });
320
320
  return;
321
321
  }
322
322
  case "billing": {
323
- const action = arg.toLowerCase() || "checkout";
324
- if (!["checkout", "portal"].includes(action)) { toast("Usage: /billing [checkout|portal].", "warning"); return; }
325
- const { url } = await shipClient(context, location).billing({ action: action as "checkout" | "portal" }) as { url: string };
323
+ const [action = "checkout", plan = "builder"] = arg.toLowerCase().split(/\s+/).filter(Boolean);
324
+ if (action === "refresh") {
325
+ const result = await shipClient(context, location).account() as { account: Record<string, unknown> };
326
+ await context.ui.dialog.alert({ title: "Cloud access refreshed", message: `${cloudAccountText(result.account)}\n\nYour task stays in this session. When usage is available, send “Continue the task” to resume.` });
327
+ return;
328
+ }
329
+ if (!["checkout", "portal"].includes(action) || !["builder", "pro", "max"].includes(plan)) { toast("Usage: /billing [checkout [builder|pro|max]|portal|refresh].", "warning"); return; }
330
+ if (action === "checkout") {
331
+ const { quote } = await shipClient(context, location).billing({ action: "quote", plan: plan as "builder" | "pro" | "max" }) as { quote: { terms: string; changingPlan: boolean } };
332
+ const proceed = await context.ui.dialog.confirm({ title: "Review Cloud plan", message: `${quote.terms}\n\n${quote.changingPlan ? "Review the exact proration in your browser before confirming the change." : "Continue to Stripe to review and pay."}\nYour task stays in this session. You can also connect your own provider with /login.` });
333
+ if (proceed !== true) return;
334
+ }
335
+ const { url } = await shipClient(context, location).billing({ action: action as "checkout" | "portal", plan: plan as "builder" | "pro" | "max" }) as { url: string };
326
336
  const { openBrowser } = await import("../../../lib/cloud/client.mjs");
327
337
  const opened = await openBrowser(url).catch(() => false);
328
- await context.ui.dialog.alert({ title: action === "portal" ? "Ship billing portal" : "Ship checkout", message: `${opened ? "Opened in your browser." : "Open this URL in your browser:"}\n${url}` });
338
+ await context.ui.dialog.alert({ title: action === "portal" ? "Cloud billing portal" : "Cloud checkout", message: `${opened ? "Opened in your browser." : "Open this URL in your browser:"}\n${url}\n\nAfter payment, use /billing refresh. Your task stays here; send “Continue the task” when ready.` });
329
339
  return;
330
340
  }
331
341
  case "logout-cloud": {
332
342
  const session = arg || undefined;
333
- const confirmed = await context.ui.dialog.confirm({ title: session ? "Revoke Ship session" : "Sign out of Carthagent Ship", message: session ? `Revoke Ship session ${session}?` : "Revoke the active Ship session and remove its Carthagent connection?" });
343
+ const confirmed = await context.ui.dialog.confirm({ title: session ? "Revoke Cloud session" : "Sign out of Carthagent Cloud", message: session ? `Revoke Cloud session ${session}?` : "Revoke the active Cloud session and remove its Carthagent connection?" });
334
344
  if (confirmed !== true) return;
335
345
  await shipClient(context, location).logout(session ? { session } : {});
336
- toast(session ? `Revoked Ship session ${session}.` : "Revoked and removed the active Carthagent Ship connection.", "success");
346
+ toast(session ? `Revoked Cloud session ${session}.` : "Revoked and removed the active Carthagent Cloud connection.", "success");
337
347
  return;
338
348
  }
339
349
  case "archive": {
@@ -95,7 +95,7 @@ export function shipClient(context: RpcContext, selectedLocation?: SelectedLocat
95
95
  return {
96
96
  account: (signal?: AbortSignal) => rpc.shipAccount({}, requestOptions(context, selectedLocation, signal)),
97
97
  usage: (input: { limit?: number } = {}, signal?: AbortSignal) => rpc.shipUsage(input, requestOptions(context, selectedLocation, signal)),
98
- billing: (input: { action: "checkout" | "portal" }, signal?: AbortSignal) => rpc.shipBilling(input, requestOptions(context, selectedLocation, signal)),
98
+ billing: (input: { action: "checkout" | "portal" | "quote"; plan?: "builder" | "pro" | "max" }, signal?: AbortSignal) => rpc.shipBilling(input, requestOptions(context, selectedLocation, signal)),
99
99
  logout: (input: { session?: string } = {}, signal?: AbortSignal) => rpc.shipLogout(input, requestOptions(context, selectedLocation, signal)),
100
100
  };
101
101
  }
@@ -38,12 +38,12 @@ export const COMMANDS: readonly CarthagentCommand[] = [
38
38
  { id: "architecture", name: "architecture", description: "Open the delivery workspace architecture view" },
39
39
  { id: "evidence", name: "evidence", description: "Open observed delivery evidence" },
40
40
  { id: "login", name: "login", aliases: ["auth"], description: "Connect Ship, Claude, xAI, Codex/OpenAI, or Gemini" },
41
- { id: "account", name: "account", aliases: ["cloud"], description: "Show Ship account, credits, plan, and sessions" },
41
+ { id: "account", name: "account", aliases: ["cloud"], description: "Show Cloud account, credits, plan, and sessions" },
42
42
  { id: "voyages", name: "voyages", description: "Open Voyages profile, wallet, equipment, shop, and daily quests" },
43
43
  { id: "quests", name: "quests", description: "Open daily coding quests; evidence requires separate explicit consent" },
44
44
  { id: "usage", name: "usage", description: "Show recent Ship usage from the control plane" },
45
45
  { id: "billing", name: "billing", args: "[checkout|portal]", description: "Open Ship checkout or billing portal" },
46
- { id: "logout-cloud", name: "logout-cloud", args: "[session-id]", description: "Revoke a Ship session or sign out this profile" },
46
+ { id: "logout-cloud", name: "logout-cloud", args: "[session-id]", description: "Revoke a Cloud session or sign out this profile" },
47
47
  { id: "archive", name: "archive", args: "[search <query>|read <id>|export <id>]", description: "Browse bounded read-only archived sessions" },
48
48
  { id: "demo", name: "demo", description: "Run a scripted, provider-free delivery walkthrough" },
49
49
  { id: "ctg-workspace", name: "ctg-workspace", args: "[refresh]", description: "Browse and open local CTG workspace projects, tasks, and sessions" },
@@ -132,7 +132,7 @@ export function DailyQuestsPage(props: { sessionID?: string; questID?: string })
132
132
  const state = view(), detail = state.detail;
133
133
  const basic = [
134
134
  { name: "Refresh daily assignments and recovery journal", description: "Read-only; never uploads or replays evidence", value: "refresh" },
135
- { name: "Sign in / reconnect", description: "Native Carthagent Ship device sign-in; not grading consent", value: "signin" },
135
+ { name: "Sign in / reconnect", description: "Native Carthagent Cloud device sign-in; not grading consent", value: "signin" },
136
136
  { name: "Back to Voyages", description: "Discard unsent editor contents", value: "back" },
137
137
  ];
138
138
  if (!state.snapshot) return basic;
@@ -60,7 +60,7 @@ export function VoyagesPage(props: { sessionID?: string; section?: "profile" | "
60
60
  const basic = [
61
61
  { name: "Refresh authoritative state", description: "Reload live profile and catalog", value: "refresh" },
62
62
  { name: "Daily coding quests", description: "Server assignments · selected evidence · explicit external JEV consent", value: "daily" },
63
- { name: "Sign in / reconnect", description: "Native provider connection · choose Carthagent Ship device sign-in", value: "signin" },
63
+ { name: "Sign in / reconnect", description: "Native provider connection · choose Carthagent Cloud device sign-in", value: "signin" },
64
64
  { name: "Back", description: props.sessionID ? "Return to selected session" : "Return home", value: "back" },
65
65
  ...(props.section ? [{ name: props.section === "profile" ? "Equipment & shop" : "Profile & wallet", description: "Open the other Voyages detail view", value: "section" }] : []),
66
66
  ];
@@ -102,11 +102,11 @@ test('check fails clearly when no delivery report exists', t => {
102
102
  assert.match(result.stderr, /No delivery report/);
103
103
  });
104
104
 
105
- test('guidance routing is not a user-facing command and Ship account commands are documented', t => {
105
+ test('guidance routing is not a user-facing command and Cloud account commands are documented', t => {
106
106
  const result = run(fixture(t), '--help');
107
107
  assert.equal(result.status, 0, result.stderr);
108
108
  assert.doesNotMatch(result.stdout, /\broute\b|\bprofiles\b/);
109
- assert.match(result.stdout, /account.*Ship credit/);
109
+ assert.match(result.stdout, /account.*Cloud credit/);
110
110
  assert.match(result.stdout, /billing.*checkout\|portal/);
111
111
  assert.match(result.stdout, /logout-cloud/);
112
112
  });
@@ -90,7 +90,7 @@ test('Cloud client sends account, billing, and session requests with bearer auth
90
90
 
91
91
  test('OAuth and account formatters keep token expiration and account UX deterministic', () => {
92
92
  assert.deepEqual(oauthCredentials({ accessToken: 'a', refreshToken: 'r', expiresIn: 900 }, 1_000), { access: 'a', refresh: 'r', expires: 901_000 });
93
- assert.match(formatCloudAccount({ accountId: 'acct', balance: { availableNanoUsd: 1_000_000_000 }, subscription: { status: 'none' }, sessions: [{}, { revoked_at: 'x' }] }), /Credit: \$1\.00 available[\s\S]*Plan: Free[\s\S]*1 active, 1 revoked/);
93
+ assert.match(formatCloudAccount({ accountId: 'acct', balance: { availableNanoUsd: 1_000_000_000 }, subscription: { status: 'none' }, sessions: [{}, { revoked_at: 'x' }] }), /Credit: \$1\.00 available[\s\S]*Plan: No paid plan[\s\S]*1 active, 1 revoked/);
94
94
  const managed = formatCloudAccount({
95
95
  accountId: 'paid', balance: { availableNanoUsd: 8_000_000_000 },
96
96
  subscription: { status: 'active', current_period_end: 1_800_000_000 },
@@ -123,7 +123,7 @@ test('OAuth and account formatters keep token expiration and account UX determin
123
123
  test('managed usage recovery distinguishes daily, monthly, subscription, and credit paths', () => {
124
124
  assert.match(cloudManagedUsageRecovery('daily_limit_reached'), /00:00 UTC/);
125
125
  assert.match(cloudManagedUsageRecovery('monthly_allowance_exhausted'), /billing portal/);
126
- assert.match(cloudManagedUsageRecovery({ code: 'subscription_inactive' }), /subscription is inactive/);
126
+ assert.match(cloudManagedUsageRecovery({ code: 'subscription_inactive' }), /awaiting payment or activation/);
127
127
  assert.match(cloudManagedUsageRecovery('insufficient_quota'), /billing checkout/);
128
128
  assert.equal(cloudManagedUsageRecovery('upstream timeout'), null);
129
129
  });
@@ -31,7 +31,7 @@ test('Cloud extension gives limit-specific recovery without hiding BYOK', () =>
31
31
  assert.equal(notices.length, 3);
32
32
  assert.match(notices[0][0], /00:00 UTC/);
33
33
  assert.match(notices[1][0], /billing portal/);
34
- assert.match(notices[2][0], /subscription is inactive/);
34
+ assert.match(notices[2][0], /awaiting payment or activation/);
35
35
  assert.ok(notices.every(([, tone]) => tone === 'warning'));
36
- assert.ok(notices.every(([message]) => /\/login for BYOK/.test(message)));
36
+ assert.ok(notices.every(([message]) => /\/login/.test(message) && /BYOK|your own provider/.test(message)));
37
37
  });
@@ -272,7 +272,7 @@ test('registerExperientialProvider registers one shared provider definition', ()
272
272
  assert.equal(registerExperientialProvider(runtime), runtime);
273
273
  assert.equal(calls.length, 1);
274
274
  assert.equal(calls[0].id, 'experiential-labs');
275
- assert.equal(calls[0].config.name, 'Carthagent Ship');
275
+ assert.equal(calls[0].config.name, 'Carthagent Cloud');
276
276
  });
277
277
 
278
278
  test('ModelRuntime resolves EXPLABS_API_KEY without copying it into provider config', async () => {
@@ -8,7 +8,9 @@ test('native login policy stays small, named, ordered and excludes upstream prov
8
8
  assert.equal(nativeLoginProvider('opencode'), undefined);
9
9
  assert.equal(nativeLoginProvider('opencode-go'), undefined);
10
10
  assert.equal(nativeLoginProvider('openrouter'), undefined);
11
- assert.equal(nativeProviderName('ship'), 'Carthagent Ship');
11
+ assert.equal(nativeProviderName('ship'), 'Carthagent Cloud');
12
+ assert.equal(nativeProviderName('Carthagent Ship'), 'Carthagent Cloud');
13
+ assert.equal(nativeProviderName('cloud'), 'Carthagent Cloud');
12
14
  assert.equal(nativeLoginProvider('claudecode').id, 'anthropic');
13
15
  assert.equal(nativeLoginProvider('codex').id, 'openai');
14
16
  assert.equal(nativeLoginProvider('gemini').id, 'google');
@@ -20,7 +22,7 @@ test('curated login rows preserve actual methods and do not invent subscription
20
22
  const ship = { id: 'experiential-labs', name: 'internal', methods: [{ type: 'oauth', id: 'carthagent-device' }] };
21
23
  const rows = nativeLoginProviders([{ id: 'opencode', methods: [] }, anthropic, ship]);
22
24
  assert.deepEqual(rows.map(row => row.id), ['experiential-labs', 'anthropic']);
23
- assert.equal(rows[0].name, 'Carthagent Ship');
25
+ assert.equal(rows[0].name, 'Carthagent Cloud');
24
26
  assert.deepEqual(rows[1].methods, [{ type: 'key' }]);
25
27
  assert.equal(anthropic.name, 'Anthropic');
26
28
  assert.equal(ship.name, 'internal');
@@ -20,6 +20,7 @@ function setup() {
20
20
  const cloud = {
21
21
  account: async (input) => { calls.push(['account', input]); return { email: 'fixture@example.test', sessions: [] }; },
22
22
  usage: async (input) => { calls.push(['usage', input]); return { data: [{ alias: 'fixture-model', inputTokens: 12, outputTokens: 8 }] }; },
23
+ quote: async (input) => { calls.push(['quote', input]); return { introEligible: false, terms: 'Builder is $10/month until cancelled.' }; },
23
24
  checkout: async (input) => { calls.push(['checkout', input]); return { url: 'https://billing.example.test/checkout' }; },
24
25
  portal: async (input) => { calls.push(['portal', input]); return { url: 'https://billing.example.test/portal' }; },
25
26
  revokeCurrent: async (input) => calls.push(['revokeCurrent', input]),
@@ -47,7 +48,18 @@ test('Ship account and billing RPCs use the active profile credential and inject
47
48
  ['portal', 'fixture-token', true],
48
49
  ]);
49
50
  assert.ok(calls.some(([name, input]) => name === 'usage' && input.limit === 10));
50
- assert.ok(calls.some(([name, input]) => name === 'checkout' && input.successUrl === 'https://ship.example.test/account'));
51
+ assert.ok(calls.some(([name, input]) => name === 'checkout' && input.successUrl === 'https://ship.example.test/v1/browser/billing/complete' && input.priceId === 'builder'));
52
+ await handlers.shipBilling({ action: 'checkout', plan: 'max' }, call);
53
+ assert.equal(calls.at(-1)[1].priceId, 'max');
54
+ });
55
+
56
+ test('native plan review uses authenticated server eligibility without creating checkout', async () => {
57
+ const { calls, handlers } = setup();
58
+ const result = await handlers.shipBilling({ action: 'quote', plan: 'builder' }, call);
59
+ assert.equal(result.quote.introEligible, false);
60
+ assert.equal(result.quote.terms, 'Builder is $10/month until cancelled.');
61
+ assert.ok(calls.some(([name, input]) => name === 'quote' && input.accessToken === 'fixture-token' && input.plan === 'builder' && input.signal === call.signal));
62
+ assert.equal(calls.some(([name]) => name === 'checkout'), false);
51
63
  });
52
64
 
53
65
  test('Ship logout revokes either one session or current token and removes current connection', async () => {
@@ -62,7 +74,7 @@ test('Ship logout revokes either one session or current token and removes curren
62
74
  test('Ship RPC requires its managed OAuth credential and honors cancellation before cloud traffic', async () => {
63
75
  const { ctx, calls, handlers } = setup();
64
76
  ctx.integration.connection.resolve = async () => ({ type: 'oauth', methodID: 'other', access: 'wrong' });
65
- await assert.rejects(handlers.shipAccount({}, call), { code: 'unauthenticated', message: 'An active Carthagent Ship OAuth connection is required.' });
77
+ await assert.rejects(handlers.shipAccount({}, call), { code: 'unauthenticated', message: 'An active Carthagent Cloud OAuth connection is required.' });
66
78
  assert.equal(calls.some(([name]) => name === 'account'), false);
67
79
  const controller = new AbortController();
68
80
  controller.abort();
@@ -42,6 +42,40 @@ test('native CLI selects headless execution and preserves output-mode options',
42
42
  assert.equal(invocation.prompt, 'Do work');
43
43
  });
44
44
 
45
+ test('headless continuation explains persistent budget exhaustion and resets only on explicit request', async t => {
46
+ const { mkdtempSync, rmSync } = await import('node:fs');
47
+ const { tmpdir } = await import('node:os');
48
+ const cwd = mkdtempSync(join(tmpdir(), 'ctg-budget-continuation-'));
49
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
50
+ const { io, output } = ioCapture();
51
+ const calls = [];
52
+ let stopped = 'elapsed-time';
53
+ const runtime = await createOpenCodeCliRuntime({ cwd, io, stdout: { write() {} }, native: {
54
+ runtime: {}, service: { ensureService: async () => ({}) }, rpcDefinitions: { Carthagent: {} },
55
+ clientFactory: async () => ({
56
+ rpc: () => ({ budgetStatus: async () => ({ stopped }) }),
57
+ session: {
58
+ list: async () => ({ data: [{ id: 'same-session', location: { directory: cwd } }] }),
59
+ get: async () => ({ id: 'same-session', location: { directory: cwd } }),
60
+ command: async input => { calls.push(input); stopped = null; },
61
+ },
62
+ }),
63
+ headless: { runHeadless: async (_service, _cwd, _prompt, options) => {
64
+ assert.equal(options.sessionID, 'same-session');
65
+ calls.push('prompt');
66
+ return { session: { id: 'same-session' }, outcome: stopped ? 'interrupted' : 'succeeded', terminal: true,
67
+ messages: stopped ? [] : [{ type: 'assistant', finish: 'stop', content: [{ type: 'text', text: 'continued' }] }] };
68
+ } },
69
+ } });
70
+ assert.equal(parseOpenCodeArgs(['--continue', '--reset-budget', 'Continue']).resetBudget, true);
71
+ assert.equal(await runtime.run({ mode: 'headless', options: { continue: true }, prompt: 'Continue' }), 1);
72
+ assert.match(output.stderr.at(-1), /budget exhausted: elapsed-time.*--reset-budget/);
73
+ assert.deepEqual(calls, ['prompt'], 'no implicit budget reset or prompt retry');
74
+ assert.equal(await runtime.run({ mode: 'headless', options: { continue: true, resetBudget: true }, prompt: 'Continue' }), 0);
75
+ assert.deepEqual(calls.slice(1), [{ sessionID: 'same-session', name: 'delivery-budget-reset', text: '' }, 'prompt']);
76
+ await assert.rejects(runtime.run({ mode: 'headless', options: { resetBudget: true }, prompt: 'New task' }), /requires --session/);
77
+ });
78
+
45
79
  test('headless CLI validates prompts and maps cancellation to conventional exit code', async () => {
46
80
  const { io, output } = ioCapture();
47
81
  assert.equal(await runOpenCodeCli({ argv: [], stdoutIsTTY: false, io, runtime: {} }), 1);
@@ -77,10 +111,10 @@ test('model, auth, cloud and domain entrypoints dispatch to injected native capa
77
111
  ]);
78
112
  });
79
113
 
80
- test('help preserves command aliases, Ship operations and isolation semantics', async () => {
114
+ test('help preserves command aliases, Cloud operations and isolation semantics', async () => {
81
115
  const { output, io } = ioCapture();
82
116
  assert.equal(await runOpenCodeCli({ argv: ['--help'], io, version: '1.0.3' }), 0);
83
- assert.match(output.stdout[0], /account.*Ship credit/);
117
+ assert.match(output.stdout[0], /account.*Cloud credit/);
84
118
  assert.match(output.stdout[0], /billing.*checkout\|portal/);
85
119
  assert.match(output.stdout[0], /--isolate\s+Isolate extension\/skill discovery \(not a worktree\)/);
86
120
  assert.equal(cliHelp('x').includes('--engine-cli, --agent-cli'), true);
@@ -1011,8 +1045,8 @@ test('native Ship billing and logout explain how to connect when the typed auth
1011
1045
  shipLogout: async () => { throw Object.assign(new Error('Not authenticated'), { type: 'ship.unauthenticated' }); },
1012
1046
  }) }),
1013
1047
  } });
1014
- await assert.rejects(runtime.billing({ action: 'checkout' }), /not connected.*ctg login.*Carthagent Ship/i);
1015
- await assert.rejects(runtime.logout({}), /not connected.*ctg login.*Carthagent Ship/i);
1048
+ await assert.rejects(runtime.billing({ action: 'checkout' }), /not connected.*ctg login.*Carthagent Cloud/i);
1049
+ await assert.rejects(runtime.logout({}), /not connected.*ctg login.*Carthagent Cloud/i);
1016
1050
  });
1017
1051
 
1018
1052
  test('OAuth forms honor conditional fields, defaults, validation, and typed selections', async () => {
@@ -535,7 +535,7 @@ test('native sign-in starts fresh device authorization after opt-in legacy migra
535
535
  const browserUrls = [];
536
536
  let generation = 0;
537
537
  const cleanup = await registerProviders(fake.ctx, {
538
- env: { CARTHAGENT_CLOUD_URL: 'https://cloud.test' }, agentDir: directory, now: () => 1000,
538
+ env: { CARTHAGENT_CLOUD_URL: 'https://cloud.test', CARTHAGENT_ADVANCED_AUTH: '1' }, agentDir: directory, now: () => 1000,
539
539
  openBrowser: url => { browserUrls.push(url); return false; },
540
540
  fetchImpl: async (url, init) => {
541
541
  requests.push({ url: String(url), body: JSON.parse(init.body) });
@@ -601,9 +601,10 @@ test('provider registration uses native provider transforms and cleanup; refresh
601
601
  openBrowser: () => false, requestSecret: 'test-secret',
602
602
  });
603
603
  assert.equal(typeof cleanup, 'function');
604
- assert.deepEqual(fake.integrationTransforms[0].map(item => item.integrationID), ['experiential-labs', 'experiential-labs', 'experiential-labs']);
604
+ assert.deepEqual(fake.integrationTransforms[0].map(item => item.integrationID), ['experiential-labs', 'experiential-labs']);
605
605
  assert.equal(fake.integrationTransforms[0][0].method.id, 'carthagent-device');
606
- assert.equal(fake.integrationTransforms[0][1].method.type, 'key');
606
+ assert.equal(fake.integrationTransforms[0][0].method.form, undefined, 'fresh sign-in has no migration question');
607
+ assert.equal(fake.integrationTransforms[0][1].method.type, 'env');
607
608
  assert.equal(fake.providerTransforms.find(item => item.info?.id === providerConstants.SHIP_PROVIDER_ID).info.package,
608
609
  '@opencode/ai/providers/openai-compatible');
609
610
  assert.ok(fake.hooks.some(item => item.name === 'http.request' && item.options.providerID === 'experiential-labs'));
@@ -40,7 +40,7 @@ test('profile Ship RPC uses resolved native OAuth and loopback account, billing,
40
40
  ['DELETE', '/v1/account/sessions/session-target'], ['POST', '/v1/token/revoke'],
41
41
  ]);
42
42
  assert.ok(requests.every(item => item.authorization === 'Bearer loopback-token'));
43
- assert.deepEqual(requests[2].body, { successUrl: `${baseUrl}/account`, cancelUrl: `${baseUrl}/account` });
43
+ assert.deepEqual(requests[2].body, { successUrl: `${baseUrl}/v1/browser/billing/complete`, cancelUrl: `${baseUrl}/v1/browser/billing/cancelled?plan=builder`, priceId: 'builder' });
44
44
  assert.deepEqual(requests[3].body, { returnUrl: `${baseUrl}/account` });
45
45
  assert.deepEqual(removed, ['cred-profile']);
46
46
  } finally {
@@ -61,7 +61,7 @@ test('profile Ship RPC rejects BYOK, absent auth, and pre-aborted operations wit
61
61
  active: async () => connection,
62
62
  resolve: async () => connection ? { type: 'key', key: 'byok' } : undefined,
63
63
  } } }, { cloudClient });
64
- await assert.rejects(handlers.shipAccount({}, rpcCall(new AbortController().signal)), /active Carthagent Ship OAuth/);
64
+ await assert.rejects(handlers.shipAccount({}, rpcCall(new AbortController().signal)), /active Carthagent Cloud OAuth/);
65
65
  }
66
66
  const handlers = createHandlers({ integration: { connection: { active: async () => undefined, resolve: async () => undefined } } }, { cloudClient });
67
67
  const controller = new AbortController();
@@ -36,7 +36,7 @@ test('Cloud managed-usage exhaustion is detected only for failed Cloud assistant
36
36
  assert.equal(isCloudCreditExhaustion({ type: 'message_end', message: { role: 'assistant', stopReason: 'error', errorMessage: 'credit is exhausted' } }, 'experiential-labs/model'), true);
37
37
  assert.match(cloudManagedUsageNotice({ type: 'message_end', message: { role: 'assistant', provider: 'experiential-labs', stopReason: 'error', errorMessage: '429: {"error":{"code":"daily_limit_reached"}}' } }), /00:00 UTC/);
38
38
  assert.match(cloudManagedUsageNotice({ type: 'message_end', message: { role: 'assistant', provider: 'experiential-labs', stopReason: 'error', errorMessage: 'monthly_allowance_exhausted' } }), /billing portal/);
39
- assert.match(cloudManagedUsageNotice({ type: 'message_end', message: { role: 'assistant', provider: 'experiential-labs', stopReason: 'error', errorMessage: 'subscription_inactive' } }), /subscription is inactive/);
39
+ assert.match(cloudManagedUsageNotice({ type: 'message_end', message: { role: 'assistant', provider: 'experiential-labs', stopReason: 'error', errorMessage: 'subscription_inactive' } }), /awaiting payment or activation/);
40
40
  assert.equal(isCloudCreditExhaustion({ type: 'message_end', message: { role: 'assistant', provider: 'openai', stopReason: 'error', errorMessage: 'daily_limit_reached' } }), false);
41
41
  assert.equal(isCloudCreditExhaustion({ type: 'message_end', message: { role: 'assistant', provider: 'experiential-labs', stopReason: 'stop' } }), false);
42
42
  });
@@ -69,7 +69,7 @@ test('active model synchronization refreshes welcome blocks without redundant in
69
69
 
70
70
  syncActiveModel(state, feed, { provider: 'experiential-labs', id: 'gpt-6-luna' });
71
71
  assert.equal(state.model, 'experiential-labs/gpt-6-luna');
72
- assert.equal(feed.blocks[0].model, 'Carthagent Ship/gpt-6-luna');
72
+ assert.equal(feed.blocks[0].model, 'Carthagent Cloud/gpt-6-luna');
73
73
  });
74
74
 
75
75
  const SAMPLE_PLAN = {
@@ -28,7 +28,9 @@ export async function fixtureStopDiagnostics({ child, output, elapsedMs }, { run
28
28
  ? ['-NoProfile', '-NonInteractive', '-Command', `$p = Get-Process -Id ${child.pid} -ErrorAction SilentlyContinue; if ($null -ne $p) { Write-Output ("pid={0} responding={1}" -f $p.Id, $p.Responding) }`]
29
29
  : ['-p', String(child.pid), '-o', 'pid=,ppid=,stat=,etime=,time='];
30
30
  try {
31
- const { stdout } = await run(command, args, { timeout: 2000, maxBuffer: 4096, windowsHide: true });
31
+ // Cold PowerShell startup on shared Windows runners can exceed two seconds.
32
+ // Keep diagnostics bounded without mistaking startup latency for no process.
33
+ const { stdout } = await run(command, args, { timeout: windows ? 10000 : 2000, maxBuffer: 4096, windowsHide: true });
32
34
  const state = stdout.trim();
33
35
  // Only numeric process metadata and fixed state codes; never argv, names,
34
36
  // environment, stderr, database contents, or OS command error messages.
@@ -488,7 +488,13 @@ def main():
488
488
  visible = screen.visible().lower()
489
489
  visible_original = screen.visible()
490
490
  if 'connection lost' in visible or 'reconnecting to the server' in visible:
491
- raise RuntimeError(f'native host reconnect overlay during terminal acceptance: {visible_original!r}')
491
+ # This isolated fixture is deleted on exit. Preserve its
492
+ # bounded diagnostics in the failure rather than losing the
493
+ # server-side reason with the temporary profile.
494
+ logs = {str(log.relative_to(root)): log.read_text(errors='replace')[-12000:]
495
+ for log in Path(root).rglob('*.log')}
496
+ raise RuntimeError(f'native host reconnect overlay during terminal acceptance: '
497
+ f'{visible_original!r}; fixture logs={logs!r}')
492
498
  home_ready = 'shift+tab agents' in visible and 'ctrl+p commands' in visible
493
499
  onboarding_visible = ('connect opencode go' in visible or
494
500
  'connect an integration to' in visible or
@@ -230,8 +230,10 @@ try {
230
230
  assert.ok(ship, 'the native provider catalog must supply a real Ship integration record');
231
231
  assert.ok(ship.methods.some(({ type, id }) => type === 'oauth' && id === 'carthagent-device'),
232
232
  'plugin integration transform must register the Ship device-OAuth method');
233
- assert.ok(ship.methods.some(({ type }) => type === 'key'),
234
- 'plugin integration transform must register the Ship API-key method');
233
+ assert.equal(ship.methods.some(({ type }) => type === 'key'), false,
234
+ 'fresh Cloud sign-in must hide the advanced API-key method');
235
+ assert.equal(ship.methods.find(({ type, id }) => type === 'oauth' && id === 'carthagent-device').form?.length ?? 0, 0,
236
+ 'fresh Cloud sign-in must not prompt for legacy import');
235
237
  assert.ok(ship.methods.some(({ type, names }) => type === 'env' && names?.includes('EXPLABS_API_KEY')),
236
238
  'the native integration catalog resolves BYOK from the named environment credential');
237
239
  const models = await client.model.list({ location: { directory: cwd } }, request);
@@ -123,7 +123,7 @@ try {
123
123
  } while (Date.now() < deadline);
124
124
  console.log(JSON.stringify({ modelRequests, models: models.map(model => ({ id: model.id, providerID: model.providerID, connected: model.connected, status: model.status })) }));
125
125
  assert.ok(models.some(model => model.id === 'ctg-ship-fixture'), 'Successful Ship OAuth must publish discovered models in the real native model inventory without EXPLABS_API_KEY');
126
- assert.equal(ship.name, 'Carthagent Ship', 'The connect picker must show the Ship name');
126
+ assert.equal(ship.name, 'Carthagent Cloud', 'The connect picker must show the Cloud name');
127
127
  assert.deepEqual(integrations.data.map(item => item.id).sort(), ['anthropic', 'experiential-labs', 'google', 'openai', 'xai'], 'Login must contain only the curated major integrations');
128
128
  const providers = (await client.provider.list({ location }, request)).data;
129
129
  assert.ok(!providers.some(provider => /^opencode(?:-|$)/.test(provider.id)), 'The upstream provider must not be available');