carthagent 1.1.0-beta.2 → 1.1.0-beta.4

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.
@@ -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.
@@ -0,0 +1,18 @@
1
+ # Carthagent 1.1.0-beta.4
2
+
3
+ ## Cloud-first onboarding
4
+
5
+ - Fresh home shows **Continue with Carthagent Cloud** and explains sign-in and eligible starter credit.
6
+ - `/login` opens Cloud authorization directly. `/connect` opens the provider picker with Cloud first under Recommended.
7
+ - Terminal `ctg login` accepts Enter to choose its recommended Cloud default.
8
+
9
+ ## Continuing useful tasks
10
+
11
+ - An explicit model selection when resuming a headless session now changes that session's model before continuing.
12
+ - Headless observation allows at least 30 minutes instead of interrupting tasks after two minutes. Persistent execution budgets still apply, and `--reset-budget` remains an explicit authorization.
13
+
14
+ ## Validation scope
15
+
16
+ The onboarding candidate was exercised in real narrow and wide terminals, with email authorization and actual model calls. A real Cloud ledger and Stripe test-mode payment verified quota rejection, payment activation, and explicit paid continuation of the same session. Payment alone did not restart the task or modify its files.
17
+
18
+ Independent review rejected the generated expense-reporting task as unfinished and incorrect; this release does not claim successful completion of that task or production inference acceptance. See [the acceptance record](cloud-fresh-install-acceptance.md) for details and the local-profile backup incident.
@@ -100,6 +100,7 @@ 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
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
  }
@@ -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: {
@@ -637,6 +644,11 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
637
644
  let result;
638
645
  let headlessError;
639
646
  const requestedSession = options.session;
647
+ if (requestedSession && explicitModelSelection && model && !signal.controller.signal.aborted) {
648
+ const resumed = await client.session.get({ sessionID: requestedSession }, { signal: signal.controller.signal });
649
+ assertSessionWorkspace(resumed, cwd);
650
+ await client.session.switchModel({ sessionID: requestedSession, model }, { location: { directory: cwd }, signal: signal.controller.signal });
651
+ }
640
652
  if (requestedSession && Object.keys(sessionSettings).length && !signal.controller.signal.aborted) {
641
653
  await persistSessionOptions(client, requestedSession, metadata, sessionSettings, { signal: signal.controller.signal, directory: cwd });
642
654
  }
@@ -644,8 +656,16 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
644
656
  const definitions = native.rpcDefinitions ?? await nativeRpcDefinitions();
645
657
  await client.rpc(definitions.SessionControls).framing({ sessionID: requestedSession, enabled: options.autoFraming }, { location: { directory: cwd }, signal: signal.controller.signal });
646
658
  }
659
+ if (requestedSession && options.resetBudget && !signal.controller.signal.aborted) {
660
+ const resumed = await client.session.get({ sessionID: requestedSession }, { signal: signal.controller.signal });
661
+ assertSessionWorkspace(resumed, cwd);
662
+ await client.session.command({ sessionID: requestedSession, name: 'delivery-budget-reset', text: '' }, { location: { directory: cwd }, signal: signal.controller.signal });
663
+ }
647
664
  try {
648
665
  result = await headlessApi.runHeadless(service, cwd, framedPrompt || prompt || '', {
666
+ // The transport observer must allow useful coding tasks to finish.
667
+ // Persistent execution budgets remain enforced by the host plugin.
668
+ timeout: Math.max(30 * 60_000, (Number(sessionSettings.maxSeconds) || 0) * 1000 + 5000),
649
669
  sessionID: options.session,
650
670
  session: {
651
671
  title: promptText.slice(0, 80), ...(model ? { model } : {}), metadata,
@@ -658,7 +678,16 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
658
678
  headlessError = error;
659
679
  result = { session: { id: options.session }, messages: [], outcome: signal.controller.signal.aborted ? 'interrupted' : 'failed', terminal: true, error: error.message };
660
680
  }
661
- const latest = headlessError ? result : await reconcileHeadlessResult(result, { client, signal: signal.controller.signal });
681
+ let latest = headlessError ? result : await reconcileHeadlessResult(result, { client, signal: signal.controller.signal });
682
+ if (latest.outcome === 'interrupted' && !latest.error && !latest.session?.error && !lastAssistantError(latest.messages) && latest.session?.id && typeof client.rpc === 'function' && !signal.controller.signal.aborted) {
683
+ // An interrupt may carry no error in the host projection. Read the
684
+ // authoritative persistent budget without restarting the task.
685
+ try {
686
+ const definitions = native.rpcDefinitions ?? await nativeRpcDefinitions();
687
+ const budget = await client.rpc(definitions.Carthagent).budgetStatus({ sessionID: latest.session.id }, { location: { directory: cwd }, signal: AbortSignal.any([signal.controller.signal, AbortSignal.timeout(2000)]) });
688
+ if (budget?.stopped) latest = { ...latest, budgetReason: budget.stopped, error: latest.error ?? headlessFailureMessage({ budgetReason: budget.stopped }) };
689
+ } catch { /* Keep the original failure when budget status is unavailable. */ }
690
+ }
662
691
  eventController.abort();
663
692
  await eventReader;
664
693
  if (eventFailure) throw new Error(`Native Carthagent event stream failed: ${eventFailure.message}`, { cause: eventFailure });
@@ -760,9 +789,10 @@ export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = conso
760
789
  : interactive.find(item => item.id === 'experiential-labs') || interactive[0];
761
790
  if (!target && interactive.length > 1) {
762
791
  const prompt = readlineFactory({ input: stdin, output: stdout });
763
- const choices = interactive.map((item, index) => `${index + 1}) ${item.name}`).join('\n');
764
- const selected = await questionWithAbort(prompt, `Select provider:\n${choices}\nProvider number: `, controller.signal);
765
- const index = Number(selected) - 1;
792
+ const defaultIndex = interactive.indexOf(integration);
793
+ const choices = interactive.map((item, index) => `${index + 1}) ${item.name}${item.id === 'experiential-labs' ? ' (recommended)' : ''}`).join('\n');
794
+ const selected = await questionWithAbort(prompt, `Select provider:\n${choices}\nProvider number [${defaultIndex + 1}]: `, controller.signal);
795
+ const index = selected.trim() === '' ? defaultIndex : Number(selected) - 1;
766
796
  if (!Number.isInteger(index) || index < 0 || index >= interactive.length) throw new Error('Select a listed provider number.');
767
797
  integration = interactive[index];
768
798
  }
@@ -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
@@ -1,36 +1,36 @@
1
1
  {
2
2
  "version": 1,
3
- "releaseVersion": "1.1.0-beta.2",
3
+ "releaseVersion": "1.1.0-beta.4",
4
4
  "compatibilityVersion": "2.0.32",
5
5
  "runtimeVersion": "0.0.0-ctg.2.0.32",
6
6
  "sourceRevision": "843a6c514e606b436a7fe713422ace0a93ad3d61",
7
- "patchSha256": "ec4bb182308ed8b311513aad5c9c7d57dcd0db103f0882f3dc3517d535b4c975",
7
+ "patchSha256": "31e15c7f92f82177bf1b6106732926951c4d751bde36b61f3e1e283818ea5631",
8
8
  "lockSha256": "9d78379cd264584a495440a217b9d2ecab524c9a56f7f8b4b5bed3926daa330d",
9
9
  "assets": {
10
10
  "linux-x64": {
11
11
  "name": "opencode-linux-x64.tar.gz",
12
- "archiveSha256": "0389612a88f9b002b819e4fc12eb5bd9e337b9c5352de60dd3f523e343ac01c2",
13
- "executableSha256": "8a34735274c0b62049f6ae882dc955016fde54458efc18822fbcedb2e8eddeda"
12
+ "archiveSha256": "abdecf765542afe7e843059e73ecdd35d189ae8d02a7d41524cd7c28f02fce99",
13
+ "executableSha256": "9f998cb2d23e2c18258a5bcb9a9588228e6fef9f089b13bb2f74b80d8ee8d7f6"
14
14
  },
15
15
  "linux-arm64": {
16
16
  "name": "opencode-linux-arm64.tar.gz",
17
- "archiveSha256": "7a01012e830a2bee2195402bf0c525bd345754ee4abb72af3ba4a597ceb096e5",
18
- "executableSha256": "aea44aa7d12ee9aef5e6fd21fbfe1d23bd2aa2c731d955d5393c4fe2c4b1dcd7"
17
+ "archiveSha256": "d5e2315f3df07cb79c50c6b5221e1f5bb5ce029997ff30eaba5c71834d95d467",
18
+ "executableSha256": "38d4707b301966145cd6cc1dbf23c656a52f42d536c5ae219507f0473ebae97d"
19
19
  },
20
20
  "darwin-x64": {
21
21
  "name": "opencode-darwin-x64.tar.gz",
22
- "archiveSha256": "cf88ba7c06a75a9e2021cfbb783a23fb93f09e2fb2dce86067346c8c666a2c5b",
23
- "executableSha256": "17c67ad68d84a9c489a4d3567784aa8a2e3f7b086153693a57c1ef0a8b270a4f"
22
+ "archiveSha256": "568b1e3a5103421f0a08627b985b53becd727097644c1e5e6f5d5be393ecaea3",
23
+ "executableSha256": "4f69cbae17e1c83149cf1066adc117b0e35173158315fe8e841606510f34f770"
24
24
  },
25
25
  "darwin-arm64": {
26
26
  "name": "opencode-darwin-arm64.tar.gz",
27
- "archiveSha256": "8db89fcec1f4fe502dfbb6f0e9ad6e2ccecdc6191ad14d6afa5338e9e0990341",
28
- "executableSha256": "789dc613d191d339ef4b789d36dd32403db9422f11841659aea5a9ec4d3e6e37"
27
+ "archiveSha256": "1b164b4bcec032d7bc8276a6e585d7a469625a22408c3499f210bea562c96075",
28
+ "executableSha256": "42733a9a95257373f62ed9663ffbed2478456c537ee7f0d3f3bbdf91418de697"
29
29
  },
30
30
  "windows-x64": {
31
31
  "name": "opencode-windows-x64.tar.gz",
32
- "archiveSha256": "9f7139dea76e662109a4c80b109b92525c993a37c69aae5b45623f8d69ab9467",
33
- "executableSha256": "38edbca2f4304f2efa2afa57b654c362c14f422c141c3ebd82e1062221dccd80"
32
+ "archiveSha256": "535ecb051cf4f43f2acf3c18bec599a6739d7ff6565e32852e041addcc868806",
33
+ "executableSha256": "64b3052060df8ed07406c4b42b3a1d56e3500a7e2cd732756d615d443d9c0f33"
34
34
  }
35
35
  }
36
36
  }
@@ -13,12 +13,12 @@ export const OPENCODE_VERSION = '2.0.32';
13
13
  export const CTG_RUNTIME_VERSION = '0.0.0-ctg.2.0.32';
14
14
  export const CTG_RUNTIME_SOURCE = '843a6c514e606b436a7fe713422ace0a93ad3d61';
15
15
  export const CTG_RUNTIME_LOCK_SHA256 = '9d78379cd264584a495440a217b9d2ecab524c9a56f7f8b4b5bed3926daa330d';
16
- export const CTG_RUNTIME_PATCH_SHA256 = 'ec4bb182308ed8b311513aad5c9c7d57dcd0db103f0882f3dc3517d535b4c975';
16
+ export const CTG_RUNTIME_PATCH_SHA256 = '31e15c7f92f82177bf1b6106732926951c4d751bde36b61f3e1e283818ea5631';
17
17
  // Update only this development pin after reviewing a new checked-in patch and
18
18
  // script/ctg/prepare-development-runtime.sh. The published release pin above,
19
19
  // its build recipe, and the release manifest stay immutable until a separately
20
20
  // verified runtime release is promoted.
21
- export const CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256 = 'ec4bb182308ed8b311513aad5c9c7d57dcd0db103f0882f3dc3517d535b4c975';
21
+ export const CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256 = '31e15c7f92f82177bf1b6106732926951c4d751bde36b61f3e1e283818ea5631';
22
22
  const DEFAULT_ROOT = path.join(homedir(), '.carthagent', 'opencode');
23
23
  const require = createRequire(import.meta.url);
24
24
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carthagent",
3
- "version": "1.1.0-beta.2",
3
+ "version": "1.1.0-beta.4",
4
4
  "workspaces": [
5
5
  "plugins/carthagent"
6
6
  ],
@@ -3,7 +3,7 @@ import { Plugin } from "@opencode/plugin/tui";
3
3
  import { Show } from "solid-js";
4
4
  import { writeFile } from "node:fs/promises";
5
5
  import { resolve } from "node:path";
6
- import { nativeProviderName } from "../../../lib/opencode/provider-policy.mjs";
6
+ import { nativeLoginProvider, nativeProviderName } from "../../../lib/opencode/provider-policy.mjs";
7
7
  import { framePrompt } from "../../../lib/tui/framing.mjs";
8
8
  import { carthagentConfigPath, loadCarthagentConfig, resolveReviewMode, reviewKickoff, saveCarthagentConfig } from "../../../lib/review.mjs";
9
9
  import { COMMANDS, NATIVE_COMMAND_COLLISIONS } from "./ui/commands.js";
@@ -114,7 +114,7 @@ export default Plugin.define({
114
114
  context.ui.router.navigate({ type: "plugin", name: "daily-quests", data: { ...(sessionID ? { sessionID } : {}) } });
115
115
  return;
116
116
  case "login":
117
- context.keymap.dispatch("provider.connect");
117
+ context.keymap.dispatch("provider.connect", arg ? nativeLoginProvider(arg)?.id ?? arg : "experiential-labs");
118
118
  return;
119
119
  case "help": case "ctg-help":
120
120
  await context.ui.dialog.alert({ title: "Carthagent commands", message: COMMANDS.map((command) => `/${command.name}${command.args ? ` ${command.args}` : ""}${command.aliases?.length ? ` (${command.aliases.map((alias) => `/${alias}`).join(", ")})` : ""} — ${command.description}`).join("\n") + "\n\nCtrl+R sessions · Ctrl+T settings · Ctrl+N new session are native Carthagent actions." });
@@ -6,7 +6,7 @@ set -euo pipefail
6
6
  ROOT="$(git rev-parse --show-toplevel)"
7
7
  EXPECTED_SOURCE="843a6c514e606b436a7fe713422ace0a93ad3d61"
8
8
  EXPECTED_LOCK="9d78379cd264584a495440a217b9d2ecab524c9a56f7f8b4b5bed3926daa330d"
9
- EXPECTED_PATCH="ec4bb182308ed8b311513aad5c9c7d57dcd0db103f0882f3dc3517d535b4c975"
9
+ EXPECTED_PATCH="31e15c7f92f82177bf1b6106732926951c4d751bde36b61f3e1e283818ea5631"
10
10
  TARGET="${1:-opencode-linux-x64}"
11
11
  HOST_PLATFORM="$(node -p 'process.platform')"
12
12
  HOST_ARCH="$(node -p 'process.arch')"
@@ -34,7 +34,7 @@ if ! git diff --cached --quiet HEAD --; then
34
34
  exit 1
35
35
  fi
36
36
  expected_inputs=(
37
- "docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch"
37
+ "docs/patches/opencode-v2.0.32-session-ui-combined-v6.patch"
38
38
  "script/ctg/build-owned-runtime.sh"
39
39
  )
40
40
  actual_status="$(git status --porcelain --untracked-files=all)"
@@ -86,7 +86,7 @@ if [[ "$(bun --version)" != "1.4.2" ]]; then
86
86
  exit 1
87
87
  fi
88
88
 
89
- patch_file="$ROOT/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch"
89
+ patch_file="$ROOT/docs/patches/opencode-v2.0.32-session-ui-combined-v6.patch"
90
90
  actual_patch="$("${HASH_COMMAND[@]}" "$patch_file" | awk '{print $1}')"
91
91
  if [[ "$actual_patch" != "$EXPECTED_PATCH" ]]; then
92
92
  echo "Patch checksum mismatch: $actual_patch" >&2
@@ -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
  });
@@ -10,7 +10,7 @@ import ts from 'typescript';
10
10
  import { parseOpenCodeArgs } from '../lib/opencode/cli.mjs';
11
11
  import { normalizeSessionOptions } from '../lib/opencode/session-options.mjs';
12
12
 
13
- const patch = readFileSync(new URL('../docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch', import.meta.url), 'utf8');
13
+ const patch = readFileSync(new URL('../docs/patches/opencode-v2.0.32-session-ui-combined-v6.patch', import.meta.url), 'utf8');
14
14
  const path = 'packages/tui/src/routes/session/plan-items.ts';
15
15
  const section = patch.split(`diff --git a/${path} b/${path}\n`)[1]?.split('\ndiff --git ')[0];
16
16
  assert.ok(section, 'the plan graph parser is part of the reproducible runtime patch');
@@ -72,14 +72,19 @@ test('development runtime source and preparation recipe pin the actual Ship grap
72
72
  assert.equal(CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, digest);
73
73
  const prepare = readFileSync(new URL('../script/ctg/prepare-development-runtime.sh', import.meta.url), 'utf8');
74
74
  assert.ok(prepare.includes(`EXPECTED_PATCH="${digest}"`));
75
- assert.ok(prepare.includes('docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch'));
75
+ assert.ok(prepare.includes('docs/patches/opencode-v2.0.32-session-ui-combined-v6.patch'));
76
76
  assert.doesNotMatch(prepare, /bun (install|run|build)|gh release|npm publish/);
77
77
  });
78
78
 
79
- test('the shared release build recipe stays on the published patch', () => {
80
- const published = readFileSync(new URL('../docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch', import.meta.url));
79
+ test('the published runtime pin stays on its immutable release patch', () => {
80
+ const published = readFileSync(new URL('../docs/patches/opencode-v2.0.32-session-ui-combined-v6.patch', import.meta.url));
81
81
  const digest = createHash('sha256').update(published).digest('hex');
82
82
  assert.equal(CTG_RUNTIME_PATCH_SHA256, digest);
83
+ });
84
+
85
+ test('the candidate build recipe pins the reviewed onboarding patch', () => {
86
+ const digest = createHash('sha256').update(patch).digest('hex');
83
87
  const build = readFileSync(new URL('../script/ctg/build-owned-runtime.sh', import.meta.url), 'utf8');
84
88
  assert.ok(build.includes(`EXPECTED_PATCH="${digest}"`));
89
+ assert.ok(build.includes('docs/patches/opencode-v2.0.32-session-ui-combined-v6.patch'));
85
90
  });
@@ -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);
@@ -518,6 +552,27 @@ test('no-prompt interactive explicit model and thinking selection applies to fre
518
552
  }
519
553
  });
520
554
 
555
+ test('headless continuation switches an explicitly requested model before submitting to the same session', async () => {
556
+ const calls = [];
557
+ const runtime = await createOpenCodeCliRuntime({ cwd: '/project', stdout: { write() {} }, native: {
558
+ runtime: {}, service: { ensureService: async () => ({ url: 'http://127.0.0.1:1', headers: {} }) },
559
+ headless: { runHeadless: async (_service, _cwd, _prompt, options) => {
560
+ calls.push(['run', options.sessionID]);
561
+ assert.equal(options.timeout, 30 * 60_000);
562
+ return { session: { id: options.sessionID }, outcome: 'failed', terminal: true, messages: [] };
563
+ } },
564
+ clientFactory: async () => ({ session: {
565
+ get: async () => ({ id: 'ses_existing', location: { directory: '/project' }, metadata: {} }),
566
+ switchModel: async input => calls.push(['switch', input]),
567
+ } }),
568
+ } });
569
+ await runtime.run({ mode: 'headless', options: { session: 'ses_existing', provider: 'fixture', model: 'cheaper-model' }, prompt: 'continue' });
570
+ assert.deepEqual(calls, [
571
+ ['switch', { sessionID: 'ses_existing', model: { providerID: 'fixture', id: 'cheaper-model' } }],
572
+ ['run', 'ses_existing'],
573
+ ]);
574
+ });
575
+
521
576
  test('resumed interactive model selection verifies workspace ownership before any session mutation or launch', async t => {
522
577
  const { mkdtempSync, rmSync } = await import('node:fs');
523
578
  const { tmpdir } = await import('node:os');
@@ -542,6 +597,11 @@ test('resumed interactive model selection verifies workspace ownership before an
542
597
  mode: 'interactive', options: { session: 'ses_foreign', model: 'fixture/explicit-model', maxTools: '3' }, prompt: '',
543
598
  }), /another workspace/);
544
599
  assert.deepEqual(calls, [['get', 'ses_foreign']], 'ownership is checked before switching models, updating metadata, or launching TUI');
600
+ calls.length = 0;
601
+ await assert.rejects(runtime.run({
602
+ mode: 'headless', options: { session: 'ses_foreign', model: 'fixture/explicit-model' }, prompt: 'continue',
603
+ }), /another workspace/);
604
+ assert.deepEqual(calls, [['get', 'ses_foreign']], 'headless model switches enforce the same workspace boundary');
545
605
  });
546
606
 
547
607
  test('interactive provider or thinking flags without a model fail instead of being silently ignored', async () => {
@@ -752,18 +812,19 @@ test('resumed native sessions merge explicit launcher options into their own wor
752
812
  assert.deepEqual(framingOverrides, [{ sessionID: 'ses_existing', enabled: false }]);
753
813
  });
754
814
 
755
- test('native login uses OpenCode integration OAuth status and completion lifecycle', async () => {
815
+ test('native login defaults to Cloud on Enter and completes the host OAuth lifecycle', async () => {
756
816
  const calls = [];
757
817
  const opened = [];
818
+ const prompts = [];
758
819
  const service = { url: 'http://127.0.0.1:1', headers: {} };
759
820
  const integration = {
760
821
  id: 'experiential-labs', name: 'Carthagent Ship',
761
822
  methods: [{ id: 'carthagent-device', type: 'oauth', label: 'Sign in with Carthagent Ship' }],
762
823
  };
763
- const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, stdin: { isTTY: true }, stdout: { isTTY: true }, openBrowserImpl: async url => { opened.push(url); return true; }, native: {
824
+ const runtime = await createOpenCodeCliRuntime({ cwd: '/project', io: ioCapture().io, stdin: { isTTY: true }, stdout: { isTTY: true }, readlineFactory: () => ({ close() {}, question: async text => { prompts.push(text); return ''; } }), openBrowserImpl: async url => { opened.push(url); return true; }, native: {
764
825
  runtime: {}, service: { ensureService: async () => service }, headless: {},
765
826
  clientFactory: async () => ({ integration: {
766
- list: async input => { calls.push(['list', input]); return { data: [integration] }; },
827
+ list: async input => { calls.push(['list', input]); return { data: [{ id: 'openai', name: 'OpenAI', methods: [{ type: 'key' }] }, integration] }; },
767
828
  oauth: {
768
829
  connect: async input => { calls.push(['connect', input]); return { data: {
769
830
  attemptID: 'attempt-1', url: 'https://auth.example/device', instructions: 'Approve access', mode: 'auto',
@@ -775,6 +836,9 @@ test('native login uses OpenCode integration OAuth status and completion lifecyc
775
836
  } }),
776
837
  } });
777
838
  assert.equal(await runtime.login(), 0);
839
+ assert.match(prompts[0], /1\) Carthagent Cloud \(recommended\)/);
840
+ assert.match(prompts[0], /Provider number \[1\]:/);
841
+ assert.equal(calls[1][1].integrationID, 'experiential-labs');
778
842
  assert.deepEqual(opened, ['https://auth.example/device']);
779
843
  assert.deepEqual(calls.map(([name]) => name), ['list', 'connect', 'status']);
780
844
  assert.equal(calls[1][1].methodID, 'carthagent-device');
@@ -106,7 +106,7 @@ function workflowJob(name) {
106
106
  return lines.slice(start, end).join('\n');
107
107
  }
108
108
 
109
- const combinedPatchPath = 'docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch';
109
+ const combinedPatchPath = 'docs/patches/opencode-v2.0.32-session-ui-combined-v6.patch';
110
110
 
111
111
  function sha256(bytes) {
112
112
  return createHash('sha256').update(bytes).digest('hex');
@@ -181,7 +181,13 @@ async function fixture(t) {
181
181
  execFileSync('git', ['init', '-q'], { cwd: ctg });
182
182
  execFileSync('git', ['-c', 'user.name=Workflow Fixture', '-c', 'user.email=fixture@example.invalid', 'add', combinedPatchPath, 'script/ctg/build-owned-runtime.sh'], { cwd: ctg });
183
183
  execFileSync('git', ['-c', 'user.name=Workflow Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-qm', 'fixture candidate patch'], { cwd: ctg });
184
- await copyFile(path.join(root, 'lib/opencode/runtime.mjs'), path.join(ctg, 'lib/opencode/runtime.mjs'));
184
+ // Publication uses a final package whose trust pin has been promoted after
185
+ // prebuild; the working tree may still retain the previous public release.
186
+ const runtimeSource = await readFile(path.join(root, 'lib/opencode/runtime.mjs'), 'utf8');
187
+ await writeFile(path.join(ctg, 'lib/opencode/runtime.mjs'), runtimeSource.replace(
188
+ /CTG_RUNTIME_PATCH_SHA256 = '[a-f0-9]{64}'/,
189
+ `CTG_RUNTIME_PATCH_SHA256 = '${patchSha256}'`,
190
+ ));
185
191
 
186
192
  const manifest = {
187
193
  version: 1,
@@ -403,9 +409,9 @@ test('draft metadata validation has scoped write capability; artifact mutation s
403
409
  assert.doesNotMatch(assembly, /contents: write/);
404
410
  assert.match(publish, /contents: write/);
405
411
  assert.match(uploadBlock, /gh release upload/);
406
- assert.match(workflow, /git -C ctg show HEAD:docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v5\.patch > opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v5\.patch/);
407
- assert.match(workflow, /patch_sha256="\$\(hash_file opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v5\.patch\)"/);
408
- assert.equal((workflow.match(/git -C ctg show HEAD:docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v5\.patch > ctg\/docs\/patches\/opencode-v2\.0\.32-session-ui\.patch/g) ?? []).length, 1,
412
+ assert.match(workflow, /git -C ctg show HEAD:docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v6\.patch > opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v6\.patch/);
413
+ assert.match(workflow, /patch_sha256="\$\(hash_file opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v6\.patch\)"/);
414
+ assert.equal((workflow.match(/git -C ctg show HEAD:docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v6\.patch > ctg\/docs\/patches\/opencode-v2\.0\.32-session-ui\.patch/g) ?? []).length, 1,
409
415
  'candidate assembly must extract exact committed bytes at the pinned historical path');
410
416
  assert.match(verifyBlock, /git -C ctg show HEAD:script\/ctg\/build-owned-runtime\.sh/);
411
417
  assert.match(verifyBlock, /git -C ctg show "HEAD:\$patch_file" > ctg\/docs\/patches\/opencode-v2\.0\.32-session-ui\.patch/);
@@ -413,7 +419,7 @@ test('draft metadata validation has scoped write capability; artifact mutation s
413
419
  assert.match(workflow, /chmod \+x opencode\/script\/ctg\/build-owned-runtime\.sh/);
414
420
  assert.match(workflow, /GIT_CONFIG_KEY_0:\s*core\.autocrlf[\s\S]*?GIT_CONFIG_VALUE_0:\s*'false'/);
415
421
  assert.match(workflow, /git -C opencode config --local core\.autocrlf false/);
416
- assert.match(workflow, /patch_sha256="\$\(hash_file opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v5\.patch\)"/);
422
+ assert.match(workflow, /patch_sha256="\$\(hash_file opencode\/docs\/patches\/opencode-v2\.0\.32-session-ui-combined-v6\.patch\)"/);
417
423
  });
418
424
 
419
425
  test('review-branch prebuild source is exact dispatch SHA; publish validation is default-branch-only', { skip: requiresUbuntuWorkflowShell, timeout: 60000 }, async (t) => {
@@ -83,7 +83,7 @@ async function fixture(t, patchSha256 = developmentPatch) {
83
83
  }
84
84
 
85
85
  test('packaged release trust stays valid independently of the development patch pin', async t => {
86
- assert.equal(CTG_RUNTIME_PATCH_SHA256, 'ec4bb182308ed8b311513aad5c9c7d57dcd0db103f0882f3dc3517d535b4c975');
86
+ assert.equal(CTG_RUNTIME_PATCH_SHA256, '31e15c7f92f82177bf1b6106732926951c4d751bde36b61f3e1e283818ea5631');
87
87
  const published = await readRuntimeReleaseManifest();
88
88
  const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
89
89
  assert.equal(published.releaseVersion, packageJson.version);