carthagent 1.0.7 → 1.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -83
- package/bin/ctg.mjs +14 -732
- package/docs/beta-rewards-e2e-plan.md +48 -0
- package/docs/beta-rewards-e2e-results.md +84 -0
- package/docs/cloud-onboarding-acceptance.md +65 -0
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/launch-readiness-live-results.md +65 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +336 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +175 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-beta.1.md +60 -0
- package/docs/release-notes-beta.2.md +60 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.13.md +68 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/docs/source-preview.md +120 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/cloud/client.mjs +13 -7
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +19 -6
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1188 -0
- package/lib/opencode/cli.mjs +253 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +22 -5
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +299 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/providers/experiential.mjs +2 -2
- package/lib/providers/names.mjs +1 -1
- package/lib/tui/framing.mjs +1 -80
- package/lib/tui/models.mjs +1 -1
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
- package/plugins/carthagent/src/server/delivery.ts +227 -112
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +65 -36
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +71 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +506 -0
- package/plugins/carthagent/src/ui/client.ts +127 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/model-command.ts +32 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
- package/plugins/carthagent/src/ui/native-review.mjs +159 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
- package/plugins/carthagent/src/ui/voyages.tsx +131 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +64 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/cloud-client.test.mjs +2 -2
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +57 -2
- package/tests/delivery.test.mjs +22 -1
- package/tests/experiential-extension.test.mjs +2 -2
- package/tests/experiential.test.mjs +1 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +55 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +525 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-model-command.test.mjs +67 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +33 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +286 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-select-mouse.test.mjs +23 -0
- package/tests/native-ship-guidance.test.mjs +16 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +85 -0
- package/tests/native-ship-rpc.test.mjs +83 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +397 -0
- package/tests/native-voyages-ui.test.mjs +307 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-delivery.test.mjs +16 -0
- package/tests/opencode-providers.test.mjs +248 -20
- package/tests/opencode-runtime.test.mjs +597 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +231 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/server.test.mjs +3 -1
- package/tests/ship-telemetry.test.mjs +1 -1
- package/tests/source-preview.test.mjs +253 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/tui.test.mjs +2 -2
- package/tests/update.test.mjs +42 -5
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +45 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +882 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +102 -20
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +91 -65
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- package/tools/verify-release-version.mjs +21 -1
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
import { readFile, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { fingerprint, completionIssues, evidenceFresh, validatePlan, planD2, runCommand } from '../delivery.mjs';
|
|
10
|
+
import { lockWorkspace, selectReport } from '../workspace.mjs';
|
|
11
|
+
import { latestReport, saveReport } from '../reports.mjs';
|
|
12
|
+
import { startImplementation } from '../planning.mjs';
|
|
13
|
+
import { planningView } from '../planning-view.mjs';
|
|
14
|
+
import { validateBudgetOptions } from '../budget.mjs';
|
|
15
|
+
import { withoutRuntimeGithubToken } from '../environment.mjs';
|
|
16
|
+
import { nativeLoginProvider, nativeLoginProviders, nativeProviderName } from './provider-policy.mjs';
|
|
17
|
+
import { loadRequiredChecks } from '../delivery.mjs';
|
|
18
|
+
import { normalizeReviewMode, resolveReviewMode, loadCarthagentConfig, saveCarthagentConfig, carthagentConfigPath } from '../review.mjs';
|
|
19
|
+
|
|
20
|
+
const HERE = new URL('.', import.meta.url);
|
|
21
|
+
const ROOT = resolve(fileURLToPath(new URL('../..', HERE)));
|
|
22
|
+
let rpcDefinitions;
|
|
23
|
+
|
|
24
|
+
async function nativeRpcDefinitions() {
|
|
25
|
+
rpcDefinitions ??= (async () => {
|
|
26
|
+
const { createJiti } = await import('jiti');
|
|
27
|
+
const loader = createJiti(import.meta.url);
|
|
28
|
+
const [carthagent, archive, sessionControls] = await Promise.all([
|
|
29
|
+
loader.import('../../plugins/carthagent/src/rpc.ts'),
|
|
30
|
+
loader.import('../../plugins/carthagent/src/archive-rpc.ts'),
|
|
31
|
+
loader.import('../../plugins/carthagent/src/session-rpc.ts'),
|
|
32
|
+
]);
|
|
33
|
+
return { Carthagent: carthagent.Carthagent, Archive: archive.Archive, SessionControls: sessionControls.SessionControls };
|
|
34
|
+
})();
|
|
35
|
+
return rpcDefinitions;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function nativeModules() {
|
|
39
|
+
const runtimeRoot = process.env.CTG_NATIVE_RUNTIME_ROOT || ROOT;
|
|
40
|
+
const load = (file) => import(pathToFileURL(join(runtimeRoot, 'lib/opencode', file)).href);
|
|
41
|
+
const [runtime, service, headless] = await Promise.all([
|
|
42
|
+
load('runtime.mjs'), load('service.mjs'), load('headless.mjs'),
|
|
43
|
+
]);
|
|
44
|
+
return { runtime, service, headless, runtimeRoot, packageRoot: ROOT };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function providerModel(options) {
|
|
48
|
+
if (!options.model) return undefined;
|
|
49
|
+
const full = options.model;
|
|
50
|
+
const split = full.indexOf('/');
|
|
51
|
+
const providerID = options.provider || (split > 0 ? full.slice(0, split) : undefined);
|
|
52
|
+
const modelID = split > 0 ? full.slice(split + 1) : full;
|
|
53
|
+
return { ...(providerID ? { providerID } : {}), id: modelID };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function textParts(messages) {
|
|
57
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
58
|
+
const lastUser = list.findLastIndex(message => message?.type === 'user' || message?.info?.role === 'user' || message?.role === 'user');
|
|
59
|
+
const currentTurn = lastUser >= 0 ? list.slice(lastUser + 1) : list;
|
|
60
|
+
const assistants = currentTurn.filter(message => message?.type === 'assistant' || message?.info?.role === 'assistant' || message?.role === 'assistant');
|
|
61
|
+
const completed = assistants.findLast(message => (message?.finish ?? message?.info?.finish ?? message?.info?.status ?? message?.status) === 'stop');
|
|
62
|
+
const selected = completed ? [completed] : assistants;
|
|
63
|
+
const output = [];
|
|
64
|
+
for (const message of selected) {
|
|
65
|
+
const parts = message?.parts ?? message?.content ?? message?.info?.parts ?? [];
|
|
66
|
+
for (const part of Array.isArray(parts) ? parts : []) {
|
|
67
|
+
if (typeof part === 'string') output.push(part);
|
|
68
|
+
else if (part?.type === 'text' && typeof part.text === 'string') output.push(part.text);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return output;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function headlessSucceeded(result) {
|
|
75
|
+
const messages = Array.isArray(result?.messages) ? result.messages : result?.messages?.data ?? result?.messages?.messages ?? [];
|
|
76
|
+
const terminal = [...messages].reverse().find(message => message?.type === 'idle');
|
|
77
|
+
const assistant = [...messages].reverse().find(message => message?.type === 'assistant' || message?.info?.role === 'assistant' || message?.role === 'assistant');
|
|
78
|
+
const outcome = result?.terminal === true ? result.outcome : result?.session?.outcome ?? terminal?.outcome;
|
|
79
|
+
const finish = assistant?.finish ?? assistant?.info?.finish ?? assistant?.info?.status ?? assistant?.status;
|
|
80
|
+
const error = assistant?.error ?? assistant?.info?.error;
|
|
81
|
+
return outcome === 'succeeded' && finish === 'stop' && !error && result?.terminal !== false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function lastAssistantError(messages) {
|
|
85
|
+
const list = Array.isArray(messages) ? messages : messages?.data ?? messages?.messages ?? [];
|
|
86
|
+
const assistant = [...list].reverse().find(message => message?.type === 'assistant' || message?.info?.role === 'assistant' || message?.role === 'assistant');
|
|
87
|
+
return assistant?.error ?? assistant?.info?.error ?? assistant?.errorMessage;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function errorText(error) {
|
|
91
|
+
if (!error) return undefined;
|
|
92
|
+
if (typeof error === 'string') return error;
|
|
93
|
+
return error.message ?? error.name ?? (typeof error.data === 'string' ? error.data : undefined)
|
|
94
|
+
?? (typeof error.toString === 'function' && error.toString() !== '[object Object]' ? error.toString() : undefined);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Headless output must never be silent on failure: a script that gets exit 1
|
|
98
|
+
// with zero bytes cannot tell what went wrong. Translate the native error
|
|
99
|
+
// into one actionable line.
|
|
100
|
+
function headlessFailureMessage(latest, headlessError) {
|
|
101
|
+
const raw = errorText(latest?.error ?? latest?.session?.error ?? lastAssistantError(latest?.messages) ?? headlessError);
|
|
102
|
+
const outcome = latest?.outcome ?? latest?.session?.outcome;
|
|
103
|
+
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 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
|
+
if (raw) return `Native run failed: ${raw}`;
|
|
107
|
+
if (latest?.reason || latest?.budgetReason) return `Native run failed: ${latest.reason ?? latest.budgetReason}`;
|
|
108
|
+
return `Native run ${outcome ?? 'failed'} without a recorded error; see the runtime log under your Carthagent profile (data/opencode/log/opencode.log).`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
function hasHeadlessTerminal(result) {
|
|
113
|
+
if (result?.terminal === true && ['succeeded', 'failed', 'interrupted', 'timed-out'].includes(result?.outcome)) return true;
|
|
114
|
+
if (result?.terminal === false) return false;
|
|
115
|
+
if (result?.outcome && ['failed', 'interrupted', 'timed-out'].includes(result.outcome)) return true;
|
|
116
|
+
const messages = Array.isArray(result?.messages) ? result.messages : result?.messages?.data ?? result?.messages?.messages ?? [];
|
|
117
|
+
return typeof result?.session?.outcome === 'string'
|
|
118
|
+
|| [...messages].reverse().some(message => message?.type === 'idle' && typeof message.outcome === 'string');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nativeJsonEvents(events, sessionID, messages, terminalState = {}) {
|
|
122
|
+
const selected = events.filter(event => sessionID && event?.data?.sessionID === sessionID);
|
|
123
|
+
const output = [];
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
const emit = event => {
|
|
126
|
+
const key = event.id || JSON.stringify(event);
|
|
127
|
+
if (seen.has(key)) return;
|
|
128
|
+
seen.add(key);
|
|
129
|
+
output.push(event);
|
|
130
|
+
};
|
|
131
|
+
const ordered = [...selected].sort((left, right) => Number(left.created || 0) - Number(right.created || 0));
|
|
132
|
+
const toolNames = new Map();
|
|
133
|
+
const toolInputs = new Map();
|
|
134
|
+
for (const event of ordered) {
|
|
135
|
+
const data = event.data || {};
|
|
136
|
+
if (event.type === 'session.tool.input.started') {
|
|
137
|
+
toolNames.set(data.id, data.name || 'tool');
|
|
138
|
+
} else if (event.type === 'session.tool.input.ended') {
|
|
139
|
+
let input = {};
|
|
140
|
+
try { input = JSON.parse(data.text); } catch { input = { raw: data.text }; }
|
|
141
|
+
toolInputs.set(data.id, input);
|
|
142
|
+
} else if (event.type === 'session.tool.called') {
|
|
143
|
+
emit({ type: 'tool_execution_start', sessionID, toolCallId: data.id, toolName: toolNames.get(data.id) || data.input?.name || data.name || 'tool', args: toolInputs.get(data.id) ?? data.input?.arguments ?? data.input ?? {} });
|
|
144
|
+
} else if (event.type === 'session.tool.success') {
|
|
145
|
+
emit({ type: 'tool_execution_end', sessionID, toolCallId: data.id, toolName: toolNames.get(data.id) || data.metadata?.title || data.metadata?.name || 'tool', isError: false, result: { content: data.content || [] } });
|
|
146
|
+
} else if (event.type === 'session.tool.failed') {
|
|
147
|
+
emit({ type: 'tool_execution_end', sessionID, toolCallId: data.id, toolName: toolNames.get(data.id) || data.metadata?.title || data.metadata?.name || 'tool', isError: true, result: { content: data.content || [{ type: 'text', text: data.error?.message || 'Tool failed' }] } });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const lastUserIndex = messages.findLastIndex(message => message?.role === 'user' || message?.info?.role === 'user' || message?.type === 'user');
|
|
151
|
+
const assistants = messages.slice(lastUserIndex < 0 ? 0 : lastUserIndex + 1)
|
|
152
|
+
.filter(message => message?.role === 'assistant' || message?.info?.role === 'assistant' || message?.type === 'assistant');
|
|
153
|
+
const finalAssistant = assistants.findLast(message => {
|
|
154
|
+
const finish = message.finish ?? message.info?.finish ?? message.info?.status ?? message.status;
|
|
155
|
+
return finish && finish !== 'tool-calls';
|
|
156
|
+
});
|
|
157
|
+
if (finalAssistant) {
|
|
158
|
+
const finish = finalAssistant.finish ?? finalAssistant.info?.finish ?? finalAssistant.info?.status ?? finalAssistant.status;
|
|
159
|
+
const content = finalAssistant.parts || finalAssistant.content || finalAssistant.info?.parts || [];
|
|
160
|
+
const error = finalAssistant.error ?? finalAssistant.info?.error ?? finalAssistant.errorMessage;
|
|
161
|
+
emit({ type: 'message_end', sessionID, message: {
|
|
162
|
+
role: 'assistant', content,
|
|
163
|
+
stopReason: finish === 'stop' ? 'stop' : finish,
|
|
164
|
+
...(error ? { error } : {}),
|
|
165
|
+
} });
|
|
166
|
+
if (finish === 'error') emit({ type: 'message', sessionID, message: finalAssistant });
|
|
167
|
+
}
|
|
168
|
+
const idle = messages.findLast(message => message?.type === 'idle' && typeof message.outcome === 'string');
|
|
169
|
+
if (idle) emit({ type: 'message', sessionID, message: idle });
|
|
170
|
+
else {
|
|
171
|
+
const terminal = ordered.findLast(event => event.type === 'session.execution.failed' || event.type === 'session.execution.interrupted' || event.type === 'session.execution.succeeded');
|
|
172
|
+
if (terminal) {
|
|
173
|
+
const outcome = terminal.type === 'session.execution.failed' ? 'failed'
|
|
174
|
+
: terminal.type === 'session.execution.interrupted' ? 'interrupted' : 'succeeded';
|
|
175
|
+
emit({ type: 'message', sessionID, message: { type: 'idle', outcome, ...(terminal.data?.error ? { error: terminal.data.error } : {}) } });
|
|
176
|
+
} else if (['failed', 'interrupted', 'succeeded', 'timed-out'].includes(terminalState.outcome)) {
|
|
177
|
+
emit({ type: 'message', sessionID, message: { type: 'idle', outcome: terminalState.outcome, ...(terminalState.error ? { error: terminalState.error } : {}) } });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!sessionID && ['failed', 'interrupted', 'succeeded', 'timed-out'].includes(terminalState.outcome)) {
|
|
181
|
+
emit({ type: 'message', message: { type: 'idle', outcome: terminalState.outcome, ...(terminalState.error ? { error: terminalState.error } : {}) } });
|
|
182
|
+
}
|
|
183
|
+
return output;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function latestSessionForWorkspace(client, cwd) {
|
|
187
|
+
const response = await client.session.list({ directory: cwd });
|
|
188
|
+
const sessions = Array.isArray(response?.data) ? response.data : [];
|
|
189
|
+
return sessions
|
|
190
|
+
.filter(session => session.location?.directory === cwd || session.directory === cwd)
|
|
191
|
+
.sort((left, right) => Number(right.time?.updated || right.time?.created || 0) - Number(left.time?.updated || left.time?.created || 0))[0]?.id;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function modelWithThinking(model, thinking) {
|
|
195
|
+
if (!model || !thinking) return model;
|
|
196
|
+
return { ...model, variant: thinking };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function acceptedTurn(messages, accepted) {
|
|
200
|
+
const list = Array.isArray(messages) ? messages : messages?.data ?? messages?.messages ?? [];
|
|
201
|
+
if (!list.length) return [];
|
|
202
|
+
const acceptedIndex = accepted?.id ? list.findIndex(message => (message?.id ?? message?.info?.id) === accepted.id) : -1;
|
|
203
|
+
// With a runtime-provided admission marker, a projection that does not
|
|
204
|
+
// contain that exact message is not evidence for this turn. Falling back to
|
|
205
|
+
// the latest user in session history could surface a prior successful turn.
|
|
206
|
+
if (accepted?.id && acceptedIndex < 0) return [];
|
|
207
|
+
const lastUser = list.findLastIndex(message => message?.type === 'user' || message?.info?.role === 'user' || message?.role === 'user');
|
|
208
|
+
const start = acceptedIndex >= 0 ? acceptedIndex : lastUser;
|
|
209
|
+
return start >= 0 ? list.slice(start).slice(-200) : list.slice(-200);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function reconcileHeadlessResult(result, { client, timeoutMs = 5000, signal }) {
|
|
213
|
+
const originalMessages = acceptedTurn(result?.messages, result?.accepted);
|
|
214
|
+
// Never reconcile against old history when the current call did not reach a
|
|
215
|
+
// terminal outcome. If admission succeeded, however, its captured current-
|
|
216
|
+
// turn messages and session identity remain valid for output projection.
|
|
217
|
+
if (result?.terminal === false) return { ...result, messages: originalMessages };
|
|
218
|
+
const authoritativeOutcome = result?.outcome ?? result?.session?.outcome;
|
|
219
|
+
const explicitTerminal = result?.terminal === true
|
|
220
|
+
|| result?.terminal !== false && ['succeeded', 'failed', 'interrupted', 'timed-out'].includes(authoritativeOutcome);
|
|
221
|
+
if (explicitTerminal) {
|
|
222
|
+
const terminal = result?.terminal === true;
|
|
223
|
+
return { ...result, messages: originalMessages, outcome: authoritativeOutcome, terminal };
|
|
224
|
+
}
|
|
225
|
+
const sessionID = result?.session?.id;
|
|
226
|
+
if (!sessionID || signal?.aborted || timeoutMs <= 0) return { ...result, messages: originalMessages };
|
|
227
|
+
const deadline = Date.now() + timeoutMs;
|
|
228
|
+
const controller = new AbortController();
|
|
229
|
+
const abort = () => controller.abort(signal?.reason);
|
|
230
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
231
|
+
const timeout = setTimeout(() => controller.abort(new Error('Headless result reconciliation timed out.')), Math.max(1, timeoutMs));
|
|
232
|
+
let messages = originalMessages;
|
|
233
|
+
try {
|
|
234
|
+
while (!controller.signal.aborted && Date.now() < deadline) {
|
|
235
|
+
const request = async fn => {
|
|
236
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
237
|
+
let abortListener;
|
|
238
|
+
const aborted = new Promise((_, reject) => {
|
|
239
|
+
abortListener = () => reject(controller.signal.reason || new Error('Headless result reconciliation aborted.'));
|
|
240
|
+
controller.signal.addEventListener('abort', abortListener, { once: true });
|
|
241
|
+
});
|
|
242
|
+
try { return await Promise.race([fn({ signal: controller.signal }), aborted]); }
|
|
243
|
+
finally { controller.signal.removeEventListener('abort', abortListener); }
|
|
244
|
+
};
|
|
245
|
+
const context = await request(options => client.session.context({ sessionID }, options));
|
|
246
|
+
const projected = acceptedTurn(context?.data ?? context, result?.accepted);
|
|
247
|
+
if (projected.length) messages = projected;
|
|
248
|
+
const current = { ...result, messages };
|
|
249
|
+
if ((result?.accepted && hasHeadlessTerminal(current)) || Date.now() >= deadline) break;
|
|
250
|
+
await new Promise(resolveWait => {
|
|
251
|
+
const delay = setTimeout(done, Math.min(25, Math.max(1, deadline - Date.now())));
|
|
252
|
+
function done() { controller.signal.removeEventListener('abort', stopped); resolveWait(); }
|
|
253
|
+
function stopped() { clearTimeout(delay); controller.signal.removeEventListener('abort', stopped); resolveWait(); }
|
|
254
|
+
controller.signal.addEventListener('abort', stopped, { once: true });
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
// Reconciliation is best-effort only. Preserve the current-turn result from
|
|
259
|
+
// runHeadless; never replace it with stale or partial session history.
|
|
260
|
+
} finally {
|
|
261
|
+
clearTimeout(timeout);
|
|
262
|
+
signal?.removeEventListener('abort', abort);
|
|
263
|
+
}
|
|
264
|
+
const terminalFromProjection = messages.findLast(message => message?.type === 'idle' && ['succeeded', 'failed', 'interrupted'].includes(message.outcome));
|
|
265
|
+
return {
|
|
266
|
+
...result,
|
|
267
|
+
messages,
|
|
268
|
+
// Only trust a projected terminal when the current accepted turn is
|
|
269
|
+
// explicitly bounded and no authoritative runHeadless outcome exists.
|
|
270
|
+
...(!result?.terminal && !authoritativeOutcome && terminalFromProjection && result?.accepted ? { outcome: terminalFromProjection.outcome, terminal: true } : {}),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function formFieldActive(field, answer) {
|
|
275
|
+
return !Array.isArray(field.when) || field.when.every(condition => condition.op === 'neq' ? answer[condition.key] !== condition.value : answer[condition.key] === condition.value);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function normalizeFormValue(field, value) {
|
|
279
|
+
if (field.type === 'boolean') return typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true';
|
|
280
|
+
if (field.type === 'integer') {
|
|
281
|
+
const number = Number(value);
|
|
282
|
+
if (!Number.isSafeInteger(number)) throw new Error(`${field.title || field.key} must be an integer.`);
|
|
283
|
+
if (field.minimum != null && number < Number(field.minimum) || field.maximum != null && number > Number(field.maximum)) throw new Error(`${field.title || field.key} must be between ${field.minimum} and ${field.maximum}.`);
|
|
284
|
+
return number;
|
|
285
|
+
}
|
|
286
|
+
if (field.type === 'number') {
|
|
287
|
+
const number = Number(value);
|
|
288
|
+
if (!Number.isFinite(number)) throw new Error(`${field.title || field.key} must be a number.`);
|
|
289
|
+
if (field.minimum != null && number < Number(field.minimum) || field.maximum != null && number > Number(field.maximum)) throw new Error(`${field.title || field.key} must be between ${field.minimum} and ${field.maximum}.`);
|
|
290
|
+
return number;
|
|
291
|
+
}
|
|
292
|
+
if (field.type === 'multiselect') {
|
|
293
|
+
const values = Array.isArray(value) ? value : String(value).split(',').map(item => item.trim()).filter(Boolean);
|
|
294
|
+
if (field.minItems != null && values.length < field.minItems || field.maxItems != null && values.length > field.maxItems) throw new Error(`${field.title || field.key} must contain ${field.minItems ?? 0} to ${field.maxItems ?? 'any'} selections.`);
|
|
295
|
+
const options = new Set((field.options || []).map(option => option.value));
|
|
296
|
+
if (!field.custom && values.some(item => !options.has(item))) throw new Error(`${field.title || field.key} contains an unsupported selection.`);
|
|
297
|
+
return values;
|
|
298
|
+
}
|
|
299
|
+
if (field.type === 'string') {
|
|
300
|
+
const text = String(value);
|
|
301
|
+
if (field.minLength != null && text.length < field.minLength || field.maxLength != null && text.length > field.maxLength) throw new Error(`${field.title || field.key} must contain ${field.minLength ?? 0} to ${field.maxLength ?? 'any'} characters.`);
|
|
302
|
+
if (field.pattern && !new RegExp(field.pattern).test(text)) throw new Error(`${field.title || field.key} has an invalid format.`);
|
|
303
|
+
if (field.options?.length && !field.custom && !field.options.some(option => option.value === text)) throw new Error(`${field.title || field.key} must match one of the listed options.`);
|
|
304
|
+
return text;
|
|
305
|
+
}
|
|
306
|
+
if (field.type === 'external') throw new Error(`External authentication field ${field.key} cannot be answered by the native CLI.`);
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function shipLoginGuidance(error) {
|
|
311
|
+
const message = String(error?.data?.message || error?.message || '');
|
|
312
|
+
const type = String(error?.type || error?.data?.type || '');
|
|
313
|
+
if (type === 'ship.unauthenticated' || type === 'ship.not-connected') {
|
|
314
|
+
return 'Carthagent Cloud is not connected. Run `ctg login` and choose Carthagent Cloud, then retry.';
|
|
315
|
+
}
|
|
316
|
+
if (/active Carthagent (?:Ship|Cloud) OAuth connection is required|not authenticated|unauthorized/i.test(message)) {
|
|
317
|
+
return 'Carthagent Cloud is not connected. Run `ctg login` and choose Carthagent Cloud, then retry.';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function questionWithAbort(prompt, text, signal) {
|
|
322
|
+
signal?.throwIfAborted();
|
|
323
|
+
return new Promise((resolveQuestion, rejectQuestion) => {
|
|
324
|
+
let settled = false;
|
|
325
|
+
const cleanup = () => {
|
|
326
|
+
signal?.removeEventListener('abort', onAbort);
|
|
327
|
+
prompt.close();
|
|
328
|
+
};
|
|
329
|
+
const finish = (callback, value) => {
|
|
330
|
+
if (settled) return;
|
|
331
|
+
settled = true;
|
|
332
|
+
cleanup();
|
|
333
|
+
callback(value);
|
|
334
|
+
};
|
|
335
|
+
const onAbort = () => finish(rejectQuestion, signal.reason);
|
|
336
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
337
|
+
Promise.resolve().then(() => prompt.question(text)).then(
|
|
338
|
+
value => finish(resolveQuestion, value),
|
|
339
|
+
error => finish(rejectQuestion, error),
|
|
340
|
+
);
|
|
341
|
+
if (signal?.aborted) onAbort();
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function questionMasked(stdin, stdout, label, signal) {
|
|
346
|
+
if (!stdin?.isTTY || !stdout?.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
347
|
+
throw new Error(`Cannot securely prompt for ${label}: a raw interactive terminal is required.`);
|
|
348
|
+
}
|
|
349
|
+
stdout.write(`${label}: `);
|
|
350
|
+
const wasRaw = Boolean(stdin.isRaw);
|
|
351
|
+
stdin.setRawMode(true);
|
|
352
|
+
stdin.resume?.();
|
|
353
|
+
return new Promise((resolveQuestion, rejectQuestion) => {
|
|
354
|
+
const value = [];
|
|
355
|
+
let settled = false;
|
|
356
|
+
let cleanup = () => {
|
|
357
|
+
stdin.removeListener('data', onData);
|
|
358
|
+
stdin.setRawMode(wasRaw);
|
|
359
|
+
stdout.write('\n');
|
|
360
|
+
};
|
|
361
|
+
const finish = (callback, result) => {
|
|
362
|
+
if (settled) return;
|
|
363
|
+
settled = true;
|
|
364
|
+
signal?.removeEventListener('abort', onAbort);
|
|
365
|
+
cleanup();
|
|
366
|
+
callback(result);
|
|
367
|
+
};
|
|
368
|
+
const onData = chunk => {
|
|
369
|
+
for (const char of String(chunk)) {
|
|
370
|
+
if (char === '\u0003') { process.emit('SIGINT'); return; }
|
|
371
|
+
if (char === '\r' || char === '\n') { finish(resolveQuestion, value.join('')); return; }
|
|
372
|
+
if (char === '\u007f' || char === '\b') { if (value.length) { value.pop(); stdout.write('\b \b'); } continue; }
|
|
373
|
+
if (char >= ' ') { value.push(char); stdout.write('*'); }
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const onAbort = () => finish(rejectQuestion, signal.reason);
|
|
377
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
378
|
+
stdin.on('data', onData);
|
|
379
|
+
if (signal?.aborted) onAbort();
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function sessionOptionsMetadata(options, cwd, runtimeRoot = ROOT) {
|
|
384
|
+
const { sessionOptionsMetadata: serialize } = await import(pathToFileURL(join(runtimeRoot, 'lib/opencode/session-options.mjs')).href);
|
|
385
|
+
return serialize(options, cwd);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function persistSessionOptions(client, sessionID, metadata, options, { signal, directory } = {}) {
|
|
389
|
+
if (!sessionID || !Object.keys(metadata).length) return metadata;
|
|
390
|
+
const session = await client.session.get({ sessionID }, signal ? { signal } : undefined);
|
|
391
|
+
return persistSessionOptionsForSession(client, sessionID, session, metadata, options, { signal, directory });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function assertSessionWorkspace(session, directory) {
|
|
395
|
+
const sessionDirectory = session?.location?.directory || session?.directory;
|
|
396
|
+
if (sessionDirectory !== directory) throw new Error('Cannot apply Carthagent launch options to a session from another workspace.');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function persistSessionOptionsForSession(client, sessionID, session, metadata, options, { signal, directory } = {}) {
|
|
400
|
+
assertSessionWorkspace(session, directory);
|
|
401
|
+
const previous = session?.metadata?.['carthagent.options'];
|
|
402
|
+
const normalizedOptions = metadata?.['carthagent.options'] || options;
|
|
403
|
+
const mergedOptions = { ...(previous && typeof previous === 'object' ? previous : {}), ...(normalizedOptions || {}) };
|
|
404
|
+
const mergedMetadata = { ...(session?.metadata || {}), ...metadata, ...(Object.keys(mergedOptions).length ? { 'carthagent.options': mergedOptions } : {}) };
|
|
405
|
+
if (typeof client.session.update !== 'function') throw new Error('Native Carthagent client cannot persist per-session Carthagent options for a resumed session.');
|
|
406
|
+
await client.session.update({ sessionID, metadata: mergedMetadata }, { location: { directory }, ...(signal ? { signal } : {}) });
|
|
407
|
+
return mergedMetadata;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function loadPromptContext(options, cwd) {
|
|
411
|
+
let prompt = options.prompt || '';
|
|
412
|
+
if (options.context) {
|
|
413
|
+
const extra = await readFile(resolve(cwd, options.context), 'utf8');
|
|
414
|
+
prompt = `${prompt}\n\nAdditional task context (from ${resolve(cwd, options.context)}):\n${extra}`;
|
|
415
|
+
}
|
|
416
|
+
if (options.validators) {
|
|
417
|
+
const manifest = resolve(cwd, options.validators);
|
|
418
|
+
const content = JSON.parse(await readFile(manifest, 'utf8'));
|
|
419
|
+
if (!content || typeof content !== 'object' || Array.isArray(content)) throw new Error(`Invalid validator manifest: ${manifest}`);
|
|
420
|
+
prompt = `${prompt}\n\nRequired validator manifest: ${manifest}\n${JSON.stringify(content, null, 2)}\nThe required validators are user-owned and must not be omitted or weakened.`;
|
|
421
|
+
}
|
|
422
|
+
return prompt;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function installSignalHandlers() {
|
|
426
|
+
const controller = new AbortController();
|
|
427
|
+
const abort = () => controller.abort(Object.assign(new Error('Interrupted'), { name: 'AbortError', code: 'SIGINT' }));
|
|
428
|
+
process.once('SIGINT', abort);
|
|
429
|
+
process.once('SIGTERM', abort);
|
|
430
|
+
return { controller, dispose: () => { process.removeListener('SIGINT', abort); process.removeListener('SIGTERM', abort); } };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function childExit(child) {
|
|
434
|
+
return new Promise((resolveExit, reject) => {
|
|
435
|
+
child.once('error', reject);
|
|
436
|
+
child.once('exit', (code, signal) => resolveExit(code ?? (signal ? 1 : 0)));
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function modelRows(data) {
|
|
441
|
+
const models = Array.isArray(data?.data) ? data.data : Array.isArray(data?.all) ? data.all : [];
|
|
442
|
+
return models.map(model => ({ ...model, providerName: nativeProviderName(model.providerID || model.provider || 'Unknown') }));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function createOpenCodeCliRuntime({ cwd = process.cwd(), io = console, stdout = process.stdout, stdin = process.stdin, native = {}, readlineFactory = createInterface, openBrowserImpl = async url => (await import('../cloud/client.mjs')).openBrowser(url) } = {}) {
|
|
446
|
+
const modules = native.runtime && native.service && native.headless ? native : await nativeModules();
|
|
447
|
+
const { runtime, service: serviceApi, headless: headlessApi } = modules;
|
|
448
|
+
const runtimeRoot = modules.runtimeRoot || ROOT;
|
|
449
|
+
const makeClient = native.clientFactory || (async service => {
|
|
450
|
+
const { OpenCode } = await import('@opencode/client');
|
|
451
|
+
return OpenCode.make({ baseUrl: service.url, headers: service.headers });
|
|
452
|
+
});
|
|
453
|
+
const pluginPath = join(runtimeRoot, 'plugins/carthagent');
|
|
454
|
+
const { shipAgentDir } = await import('../ship-auth.mjs');
|
|
455
|
+
const runtimeOptions = {
|
|
456
|
+
pluginPath,
|
|
457
|
+
env: { CTG_NATIVE_ARCHIVE_ROOTS: JSON.stringify([join(shipAgentDir(process.env), 'sessions')]) },
|
|
458
|
+
...(process.env.CTG_OPENCODE_HOME ? { root: process.env.CTG_OPENCODE_HOME } : {}),
|
|
459
|
+
};
|
|
460
|
+
const ensure = (options = {}) => serviceApi.ensureService({ ...runtimeOptions, ...options });
|
|
461
|
+
const serviceClient = async (options = {}) => {
|
|
462
|
+
const service = await ensure(options);
|
|
463
|
+
return { service, client: await makeClient(service, cwd, runtimeRoot), cwd, runtimeRoot };
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
async function run({ mode, options, prompt }) {
|
|
467
|
+
if (options.demo) {
|
|
468
|
+
if (mode !== 'interactive') throw new Error('The offline native demo requires an interactive terminal.');
|
|
469
|
+
const { runOfflineDemo } = await import('./demo.mjs');
|
|
470
|
+
return runOfflineDemo({ io, nodeBinary: process.env.CTG_NODE_EXECUTABLE || process.execPath, ...(native.demoExecute ? { execute: native.demoExecute } : {}) });
|
|
471
|
+
}
|
|
472
|
+
const explicitModelSelection = options.model !== undefined || options.provider !== undefined || options.thinking !== undefined;
|
|
473
|
+
const sticky = (await import('../review.mjs')).resolveStickyDefaults(options);
|
|
474
|
+
options.theme ??= sticky.theme;
|
|
475
|
+
options.model ??= sticky.model;
|
|
476
|
+
if (!options.model && (options.provider !== undefined || options.thinking !== undefined)) {
|
|
477
|
+
throw new Error('--provider and --thinking require --model or a configured default model; the requested model cannot be resolved.');
|
|
478
|
+
}
|
|
479
|
+
const glyphMode = options.ascii ? 'ascii' : options.glyphs;
|
|
480
|
+
if (glyphMode && !['auto', 'ascii', 'unicode'].includes(glyphMode)) throw new Error('--glyphs must be auto, ascii, or unicode.');
|
|
481
|
+
let resolvedGlyphs;
|
|
482
|
+
if (mode === 'interactive') {
|
|
483
|
+
const { detectGlyphMode } = await import('../tui/glyphs.mjs');
|
|
484
|
+
const glyphEnvironment = runtime.runtimeEnvironment ? runtime.runtimeEnvironment() : process.env;
|
|
485
|
+
resolvedGlyphs = glyphMode === 'ascii' || glyphMode === 'unicode' ? glyphMode : detectGlyphMode(glyphEnvironment);
|
|
486
|
+
}
|
|
487
|
+
validateBudgetOptions(options, { allowWithoutDelivery: true });
|
|
488
|
+
for (const [key, max] of [['maxTools', 1_000_000], ['maxSeconds', 1_000_000], ['maxRepairs', 1_000_000], ['rewriteCap', 1_000_000], ['toolOutputCap', 1_000_000], ['turnDelayMs', 1_000_000]]) {
|
|
489
|
+
if (options[key] !== undefined && (!/^\d+$/.test(String(options[key])) || Number(options[key]) > max)) {
|
|
490
|
+
throw new Error(`--${key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)} must be an integer from 0 to ${max}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (options.bashCap !== undefined && (!/^\d+(?:\.\d+)?$/.test(String(options.bashCap)) || Number(options.bashCap) <= 0)) {
|
|
494
|
+
throw new Error('--bash-cap must be a positive number of seconds');
|
|
495
|
+
}
|
|
496
|
+
if (options.delivery === false && options.strict === false) throw new Error('--no-delivery and --no-strict cannot be combined; select one workflow mode.');
|
|
497
|
+
const sessionSettings = {
|
|
498
|
+
...(options.strict === undefined ? {} : { strict: options.strict }),
|
|
499
|
+
...(options.delivery === undefined ? {} : { delivery: options.delivery }),
|
|
500
|
+
...(options.autoFraming === undefined ? {} : { guide: options.autoFraming }),
|
|
501
|
+
...((options.workflow || options.strict !== undefined) ? { workflow: options.workflow || (options.strict ? 'strict' : 'standard') } : {}),
|
|
502
|
+
...(options.maxTools !== undefined ? { maxTools: options.maxTools } : {}),
|
|
503
|
+
...(options.maxSeconds !== undefined ? { maxSeconds: options.maxSeconds } : {}),
|
|
504
|
+
...(options.maxRepairs !== undefined ? { maxRepairs: options.maxRepairs } : {}),
|
|
505
|
+
...(options.bashCap !== undefined ? { bashCap: options.bashCap } : {}),
|
|
506
|
+
...Object.fromEntries(['protectExisting', 'rewriteCap', 'toolOutputCap', 'turnDelayMs', 'jev', 'jevProvider'].filter(key => options[key] !== undefined).map(key => [key, options[key]])),
|
|
507
|
+
...(options.validators ? { validators: resolve(cwd, options.validators) } : {}),
|
|
508
|
+
...(options.context ? { context: options.context } : {}),
|
|
509
|
+
...(options.review ? { review: options.review } : {}),
|
|
510
|
+
...(options.codegraphMaxFiles !== undefined ? { codegraphMaxFiles: options.codegraphMaxFiles } : {}),
|
|
511
|
+
};
|
|
512
|
+
if (options.bashCap !== undefined && options.bashCap > 0 && options.delivery === false) {
|
|
513
|
+
throw new Error('--bash-cap requires the Carthagent delivery plugin; remove --no-delivery to enable shell timeout enforcement.');
|
|
514
|
+
}
|
|
515
|
+
const metadata = { ...await sessionOptionsMetadata(sessionSettings, cwd, runtimeRoot) };
|
|
516
|
+
const requiredChecks = options.validators ? loadRequiredChecks(resolve(cwd, options.validators), cwd) : [];
|
|
517
|
+
if (requiredChecks.length) metadata['carthagent.requiredChecks'] = requiredChecks;
|
|
518
|
+
const workspaceEnv = runtime.workspaceEnvironment
|
|
519
|
+
? runtime.workspaceEnvironment(cwd)
|
|
520
|
+
: { ...process.env, CTG_WORKSPACE: cwd };
|
|
521
|
+
const promptText = await loadPromptContext(options, cwd);
|
|
522
|
+
const { shouldFrame, framePrompt } = await import('../../plugins/carthagent/src/server/framing.mjs');
|
|
523
|
+
const framedPrompt = shouldFrame(promptText, { enabled: options.autoFraming !== false, delivery: options.delivery !== false })
|
|
524
|
+
? framePrompt(promptText) : promptText;
|
|
525
|
+
const isolatedRoot = options.isolate
|
|
526
|
+
? join(runtime.runtimePaths?.(runtimeOptions).root ?? process.env.CTG_OPENCODE_HOME ?? join(homedir(), '.carthagent', 'opencode'), 'isolated', randomUUID())
|
|
527
|
+
: undefined;
|
|
528
|
+
let ownedIsolatedService = false;
|
|
529
|
+
let isolatedEnsureAttempted = false;
|
|
530
|
+
let service;
|
|
531
|
+
const model = modelWithThinking(providerModel(options), options.thinking);
|
|
532
|
+
const signal = installSignalHandlers();
|
|
533
|
+
let eventController;
|
|
534
|
+
let eventReader = Promise.resolve();
|
|
535
|
+
try {
|
|
536
|
+
isolatedEnsureAttempted = Boolean(isolatedRoot);
|
|
537
|
+
service = await ensure({
|
|
538
|
+
...(options.engineCli ? { binary: options.engineCli } : {}),
|
|
539
|
+
...(isolatedRoot ? { root: isolatedRoot, pluginPath, isolate: true } : {}),
|
|
540
|
+
...(isolatedRoot ? { profile: { project: false, global: false, claude: false, skills: [], instructions: [], commands: {}, agents: {} } } : {}),
|
|
541
|
+
});
|
|
542
|
+
ownedIsolatedService = Boolean(isolatedRoot && service.paths?.root === isolatedRoot);
|
|
543
|
+
const sessionEnv = { ...workspaceEnv, ...(isolatedRoot ? { OPENCODE_CONFIG_PROJECT_DISABLE: 'true', OPENCODE_DISABLE_CLAUDE_CODE: 'true', OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: 'true' } : {}) };
|
|
544
|
+
const client = await makeClient(service, cwd, runtimeRoot);
|
|
545
|
+
const requiredCapabilities = [];
|
|
546
|
+
if (Object.keys(sessionSettings).length) requiredCapabilities.push('delivery.session-options.v1');
|
|
547
|
+
if (options.bashCap !== undefined) requiredCapabilities.push('delivery.session-options.v1');
|
|
548
|
+
if (['maxTools', 'maxSeconds', 'maxRepairs'].some(key => options[key] !== undefined)) requiredCapabilities.push('delivery.budgets.v1');
|
|
549
|
+
if (options.delivery === false || options.strict === false) requiredCapabilities.push('delivery.policy-overrides.v1');
|
|
550
|
+
if (requiredCapabilities.length) {
|
|
551
|
+
const definitions = native.rpcDefinitions ?? await nativeRpcDefinitions();
|
|
552
|
+
const health = await client.rpc(definitions.Carthagent).health({}, { location: { directory: cwd }, signal: signal.controller.signal });
|
|
553
|
+
const advertised = health?.capabilities;
|
|
554
|
+
if (!Array.isArray(advertised) || requiredCapabilities.some(capability => !advertised.includes(capability))) {
|
|
555
|
+
throw new Error(`The active native plugin does not advertise required enforcement capabilities: ${requiredCapabilities.filter(capability => !advertised?.includes(capability)).join(', ')}.`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (mode === 'headless' && !options.session && (options.continue || options.resume)) {
|
|
559
|
+
options.session = await latestSessionForWorkspace(client, cwd);
|
|
560
|
+
if (!options.session) throw new Error('No native Carthagent session exists for this workspace to continue.');
|
|
561
|
+
}
|
|
562
|
+
eventController = new AbortController();
|
|
563
|
+
const nativeEvents = [];
|
|
564
|
+
let eventFailure;
|
|
565
|
+
const eventStream = mode === 'headless' && options.json ? client.event?.subscribe?.({ signal: eventController.signal }) : undefined;
|
|
566
|
+
eventReader = eventStream ? (async () => {
|
|
567
|
+
try { for await (const event of eventStream) nativeEvents.push(event); }
|
|
568
|
+
catch (error) { if (!eventController.signal.aborted) eventFailure = error; }
|
|
569
|
+
})() : Promise.resolve();
|
|
570
|
+
if (mode === 'interactive') {
|
|
571
|
+
let sessionID = options.session;
|
|
572
|
+
let createdSession = false;
|
|
573
|
+
if (!sessionID && (options.continue || options.resume)) {
|
|
574
|
+
sessionID = await latestSessionForWorkspace(client, cwd);
|
|
575
|
+
if (!sessionID) throw new Error('No native Carthagent session exists for this workspace to resume.');
|
|
576
|
+
}
|
|
577
|
+
let resumedSession;
|
|
578
|
+
if (sessionID && !createdSession) {
|
|
579
|
+
if (typeof client.session.get !== 'function') throw new Error('Native Carthagent client cannot verify ownership of a resumed session.');
|
|
580
|
+
resumedSession = await client.session.get({ sessionID }, { signal: signal.controller.signal });
|
|
581
|
+
assertSessionWorkspace(resumedSession, cwd);
|
|
582
|
+
}
|
|
583
|
+
if (mode === 'interactive' && !sessionID && (Object.keys(sessionSettings).length || options.autoFraming === false || explicitModelSelection)) {
|
|
584
|
+
const created = await headlessApi.createSession(service, cwd, { body: {
|
|
585
|
+
title: promptText.slice(0, 80),
|
|
586
|
+
...(model ? { model } : {}),
|
|
587
|
+
metadata,
|
|
588
|
+
} });
|
|
589
|
+
sessionID = created.id;
|
|
590
|
+
createdSession = true;
|
|
591
|
+
}
|
|
592
|
+
if (mode === 'interactive' && sessionID && !createdSession && explicitModelSelection && model) {
|
|
593
|
+
if (typeof client.session.switchModel !== 'function') throw new Error('Native Carthagent client cannot apply an explicit model selection to a resumed session.');
|
|
594
|
+
await client.session.switchModel({ sessionID, model }, { location: { directory: cwd }, signal: signal.controller.signal });
|
|
595
|
+
}
|
|
596
|
+
if (mode === 'interactive' && sessionID && !createdSession && (Object.keys(sessionSettings).length || requiredChecks.length)) {
|
|
597
|
+
await persistSessionOptionsForSession(client, sessionID, resumedSession, metadata, sessionSettings, { signal: signal.controller.signal, directory: cwd });
|
|
598
|
+
}
|
|
599
|
+
if (options.autoFraming !== undefined && mode === 'interactive' && sessionID) {
|
|
600
|
+
const definitions = native.rpcDefinitions ?? await nativeRpcDefinitions();
|
|
601
|
+
await client.rpc(definitions.SessionControls).framing({ sessionID, enabled: options.autoFraming }, { location: { directory: cwd }, signal: signal.controller.signal });
|
|
602
|
+
}
|
|
603
|
+
if (framedPrompt) {
|
|
604
|
+
if (!sessionID) {
|
|
605
|
+
const created = await headlessApi.createSession(service, cwd, { body: {
|
|
606
|
+
title: promptText.slice(0, 80),
|
|
607
|
+
...(model ? { model } : {}),
|
|
608
|
+
metadata,
|
|
609
|
+
} });
|
|
610
|
+
sessionID = created.id;
|
|
611
|
+
createdSession = true;
|
|
612
|
+
}
|
|
613
|
+
if (requiredChecks.length) {
|
|
614
|
+
const checks = requiredChecks.map(check => `${check.id}: ${JSON.stringify(check)}`).join('\n');
|
|
615
|
+
const existingContext = promptText ? `\n\nTask context:\n${promptText}` : '';
|
|
616
|
+
const validatorInstructions = `\n\nRequired user-owned validators (non-negotiable; run exactly these checks and do not remove or weaken them):\n${checks}${existingContext}`;
|
|
617
|
+
if (!framedPrompt.includes(validatorInstructions.trim())) {
|
|
618
|
+
await headlessApi.promptSession(service, sessionID, validatorInstructions, { signal: signal.controller.signal, body: { delivery: 'queue' } });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
await headlessApi.promptSession(service, sessionID, framedPrompt, { signal: signal.controller.signal, body: { metadata: { 'ctg.framed': true } } });
|
|
622
|
+
}
|
|
623
|
+
const tuiService = options.engineCli
|
|
624
|
+
? { ...service, binary: await runtime.resolveOpenCode({ binary: options.engineCli }) }
|
|
625
|
+
: service;
|
|
626
|
+
const child = serviceApi.launchTui(tuiService, cwd, {
|
|
627
|
+
sessionID,
|
|
628
|
+
continue: Boolean(options.continue),
|
|
629
|
+
env: sessionEnv,
|
|
630
|
+
theme: options.theme,
|
|
631
|
+
glyphs: resolvedGlyphs,
|
|
632
|
+
});
|
|
633
|
+
const onSignal = () => child.kill('SIGINT');
|
|
634
|
+
process.once('SIGINT', onSignal);
|
|
635
|
+
try { return await childExit(child); } finally { process.removeListener('SIGINT', onSignal); }
|
|
636
|
+
}
|
|
637
|
+
let result;
|
|
638
|
+
let headlessError;
|
|
639
|
+
const requestedSession = options.session;
|
|
640
|
+
if (requestedSession && Object.keys(sessionSettings).length && !signal.controller.signal.aborted) {
|
|
641
|
+
await persistSessionOptions(client, requestedSession, metadata, sessionSettings, { signal: signal.controller.signal, directory: cwd });
|
|
642
|
+
}
|
|
643
|
+
if (requestedSession && options.autoFraming !== undefined && !signal.controller.signal.aborted) {
|
|
644
|
+
const definitions = native.rpcDefinitions ?? await nativeRpcDefinitions();
|
|
645
|
+
await client.rpc(definitions.SessionControls).framing({ sessionID: requestedSession, enabled: options.autoFraming }, { location: { directory: cwd }, signal: signal.controller.signal });
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
result = await headlessApi.runHeadless(service, cwd, framedPrompt || prompt || '', {
|
|
649
|
+
sessionID: options.session,
|
|
650
|
+
session: {
|
|
651
|
+
title: promptText.slice(0, 80), ...(model ? { model } : {}), metadata,
|
|
652
|
+
},
|
|
653
|
+
env: sessionEnv,
|
|
654
|
+
signal: signal.controller.signal,
|
|
655
|
+
});
|
|
656
|
+
} catch (error) {
|
|
657
|
+
if (!options.json || !options.session) throw error;
|
|
658
|
+
headlessError = error;
|
|
659
|
+
result = { session: { id: options.session }, messages: [], outcome: signal.controller.signal.aborted ? 'interrupted' : 'failed', terminal: true, error: error.message };
|
|
660
|
+
}
|
|
661
|
+
const latest = headlessError ? result : await reconcileHeadlessResult(result, { client, signal: signal.controller.signal });
|
|
662
|
+
eventController.abort();
|
|
663
|
+
await eventReader;
|
|
664
|
+
if (eventFailure) throw new Error(`Native Carthagent event stream failed: ${eventFailure.message}`, { cause: eventFailure });
|
|
665
|
+
const text = textParts(latest.messages);
|
|
666
|
+
if (options.json) {
|
|
667
|
+
const sessionID = latest.session?.id;
|
|
668
|
+
const preAdmission = latest.terminal === false && !latest.accepted;
|
|
669
|
+
for (const event of nativeJsonEvents(preAdmission ? [] : nativeEvents, preAdmission ? undefined : sessionID, latest.messages || [], {
|
|
670
|
+
outcome: latest.outcome || latest.session?.outcome || (headlessError ? signal.controller.signal.aborted ? 'interrupted' : 'failed' : undefined),
|
|
671
|
+
error: latest.error || latest.session?.error || headlessError?.message,
|
|
672
|
+
})) stdout.write(`${JSON.stringify(event)}\n`);
|
|
673
|
+
if (!preAdmission && sessionID && headlessError && !nativeEvents.some(event => event.type === 'session.execution.failed' && event.data?.sessionID === sessionID)) {
|
|
674
|
+
const hasAssistantFailure = (latest.messages || []).some(message => (message?.role === 'assistant' || message?.info?.role === 'assistant' || message?.type === 'assistant')
|
|
675
|
+
&& (message.finish ?? message.info?.finish ?? message.info?.status ?? message.status) === 'error');
|
|
676
|
+
if (!hasAssistantFailure) {
|
|
677
|
+
const failedAssistant = { type: 'assistant', finish: 'error', errorMessage: headlessError.message, content: [] };
|
|
678
|
+
stdout.write(`${JSON.stringify({ type: 'message', sessionID: latest.session.id, message: failedAssistant })}\n`);
|
|
679
|
+
stdout.write(`${JSON.stringify({ type: 'message_end', sessionID: latest.session.id, message: { role: 'assistant', content: [], stopReason: 'error', error: headlessError.message } })}\n`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
} else if (text.length) stdout.write(`${text.join('\n')}\n`);
|
|
683
|
+
if (headlessSucceeded(latest)) return 0;
|
|
684
|
+
io.error(headlessFailureMessage(latest, headlessError));
|
|
685
|
+
return 1;
|
|
686
|
+
} finally {
|
|
687
|
+
eventController?.abort();
|
|
688
|
+
await eventReader;
|
|
689
|
+
signal.dispose();
|
|
690
|
+
if (isolatedRoot && isolatedEnsureAttempted && serviceApi.stopService) {
|
|
691
|
+
try {
|
|
692
|
+
const stopped = await serviceApi.stopService({ root: isolatedRoot });
|
|
693
|
+
let registrationExists = false;
|
|
694
|
+
const paths = runtime.runtimePaths?.({ root: isolatedRoot });
|
|
695
|
+
if (paths?.serviceFile) {
|
|
696
|
+
try { await readFile(paths.serviceFile); registrationExists = true; }
|
|
697
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
698
|
+
}
|
|
699
|
+
if (stopped || (!registrationExists && !ownedIsolatedService)) await rm(isolatedRoot, { recursive: true, force: true });
|
|
700
|
+
} catch (error) {
|
|
701
|
+
io.error(`Could not clean up isolated native profile ${isolatedRoot}: ${error.message}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function listModels({ query = '' } = {}) {
|
|
708
|
+
const { client } = await serviceClient();
|
|
709
|
+
const data = await client.model.list({ location: { directory: cwd } });
|
|
710
|
+
const term = query.trim().toLowerCase();
|
|
711
|
+
const rows = modelRows(data)
|
|
712
|
+
.filter(model => !term || `${model.providerName} ${model.providerID} ${model.id} ${model.name}`.toLowerCase().includes(term))
|
|
713
|
+
.sort((a, b) => a.providerName.localeCompare(b.providerName) || String(a.id).localeCompare(String(b.id)));
|
|
714
|
+
for (const model of rows) {
|
|
715
|
+
const inputCost = Array.isArray(model.cost) ? model.cost[0]?.input : model.cost?.input;
|
|
716
|
+
const outputCost = Array.isArray(model.cost) ? model.cost[0]?.output : model.cost?.output;
|
|
717
|
+
io.log([model.providerName, model.modelID || model.id, model.limit?.context ?? model.contextWindow, model.limit?.output ?? model.maxTokens, inputCost, outputCost]
|
|
718
|
+
.map(value => value == null ? '?' : value).join(' '));
|
|
719
|
+
}
|
|
720
|
+
if (!rows.length) {
|
|
721
|
+
io.error(term
|
|
722
|
+
? `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 Cloud is recommended), or set an API key env var (e.g. ANTHROPIC_API_KEY), then run `ctg native --list-models` again.');
|
|
724
|
+
}
|
|
725
|
+
return rows.length;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function login({ args = [] } = {}) {
|
|
729
|
+
if (typeof stdin?.isTTY === 'boolean' && !stdin.isTTY) throw new Error('login requires an interactive terminal.');
|
|
730
|
+
if (typeof stdout?.isTTY === 'boolean' && !stdout.isTTY) throw new Error('login requires an interactive terminal.');
|
|
731
|
+
const controller = new AbortController();
|
|
732
|
+
const onInterrupt = () => {
|
|
733
|
+
const error = new Error('Login cancelled.');
|
|
734
|
+
error.name = 'AbortError';
|
|
735
|
+
error.code = 'SIGINT';
|
|
736
|
+
controller.abort(error);
|
|
737
|
+
};
|
|
738
|
+
process.once('SIGINT', onInterrupt);
|
|
739
|
+
let client;
|
|
740
|
+
let location;
|
|
741
|
+
let integration;
|
|
742
|
+
let attempt;
|
|
743
|
+
let completed = false;
|
|
744
|
+
const connected = () => {
|
|
745
|
+
completed = true;
|
|
746
|
+
io.log(`Connected to ${integration.name}.`);
|
|
747
|
+
io.log('Run ctg in your project. Start small: “Find one failing test, explain the cause, and fix it with a regression test.”');
|
|
748
|
+
if (integration.id === 'experiential-labs') io.log('Use /account to check the connected account and available usage. Starter usage is granted only when eligible.');
|
|
749
|
+
};
|
|
750
|
+
try {
|
|
751
|
+
({ client } = await serviceClient());
|
|
752
|
+
location = { directory: cwd };
|
|
753
|
+
const listed = await client.integration.list({ location }, { signal: controller.signal });
|
|
754
|
+
controller.signal.throwIfAborted();
|
|
755
|
+
const integrations = nativeLoginProviders(Array.isArray(listed?.data) ? listed.data : []);
|
|
756
|
+
const target = args[0] ? nativeLoginProvider(args[0])?.id ?? args[0] : undefined;
|
|
757
|
+
const interactive = integrations.filter(item => item.methods.some(method => method.type !== 'env'));
|
|
758
|
+
integration = target
|
|
759
|
+
? integrations.find(item => item.id === target || item.name.toLowerCase() === target.toLowerCase())
|
|
760
|
+
: interactive.find(item => item.id === 'experiential-labs') || interactive[0];
|
|
761
|
+
if (!target && interactive.length > 1) {
|
|
762
|
+
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;
|
|
766
|
+
if (!Number.isInteger(index) || index < 0 || index >= interactive.length) throw new Error('Select a listed provider number.');
|
|
767
|
+
integration = interactive[index];
|
|
768
|
+
}
|
|
769
|
+
if (!integration) throw new Error(target ? `Integration not found: ${target}` : 'No provider integrations with an interactive authentication method are available.');
|
|
770
|
+
const methods = integration.methods.filter(method => method.type !== 'env');
|
|
771
|
+
if (!methods.length) throw new Error(`${integration.name} has no interactive authentication method.`);
|
|
772
|
+
const methodName = args[1];
|
|
773
|
+
const method = methodName
|
|
774
|
+
? methods.find(item => item.type === 'key' ? methodName.toLowerCase() === 'key' : item.id === methodName || item.label.toLowerCase() === methodName.toLowerCase())
|
|
775
|
+
: methods.find(item => item.type === 'oauth' && item.id === nativeLoginProvider(integration.id)?.oauth) ?? methods[0];
|
|
776
|
+
if (!method) throw new Error(`Authentication method not found: ${methodName}. Available: ${methods.map(item => item.type === 'key' ? 'key' : item.id).join(', ')}`);
|
|
777
|
+
const answer = {};
|
|
778
|
+
if (method.form?.length) {
|
|
779
|
+
for (const field of method.form) {
|
|
780
|
+
if (!formFieldActive(field, answer)) continue;
|
|
781
|
+
const title = field.title || field.key;
|
|
782
|
+
const secret = field.type === 'string' && /(?:secret|token|password|api.?key|private.?key)/i.test(`${field.key} ${title}`);
|
|
783
|
+
let value;
|
|
784
|
+
if (field.hidden) {
|
|
785
|
+
value = field.default;
|
|
786
|
+
if (value === undefined && field.required) throw new Error(`Hidden required authentication field ${field.key} has no default and cannot be prompted securely.`);
|
|
787
|
+
if (value !== undefined) answer[field.key] = normalizeFormValue(field, value);
|
|
788
|
+
else if (field.type === 'boolean') answer[field.key] = false;
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
const label = `${title}${field.required ? ' (required)' : ''}`;
|
|
792
|
+
if (secret) value = await questionMasked(stdin, stdout, label, controller.signal);
|
|
793
|
+
else if (field.type === 'boolean') {
|
|
794
|
+
const choices = ['true', 'false'];
|
|
795
|
+
const defaultValue = field.default === undefined ? 'false' : String(field.default);
|
|
796
|
+
const prompt = readlineFactory({ input: stdin, output: stdout });
|
|
797
|
+
value = await questionWithAbort(prompt, `${label} [${choices.join('/')}] (default ${defaultValue}): `, controller.signal);
|
|
798
|
+
if (!value) value = defaultValue;
|
|
799
|
+
if (!choices.includes(String(value).toLowerCase())) throw new Error(`${title} must be true or false.`);
|
|
800
|
+
} else if (field.type === 'multiselect') {
|
|
801
|
+
const optionText = (field.options || []).map(option => `${option.value} (${option.label})`).join(', ');
|
|
802
|
+
const prompt = readlineFactory({ input: stdin, output: stdout });
|
|
803
|
+
value = await questionWithAbort(prompt, `${label}${optionText ? ` [${optionText}]` : ''} (comma-separated${field.default ? `; default ${field.default.join(',')}` : ''}): `, controller.signal);
|
|
804
|
+
if (!value && field.default !== undefined) value = field.default;
|
|
805
|
+
} else {
|
|
806
|
+
const prompt = readlineFactory({ input: stdin, output: stdout });
|
|
807
|
+
const choices = field.options?.length ? ` [${field.options.map(option => `${option.value} (${option.label})`).join(', ')}]` : '';
|
|
808
|
+
value = await questionWithAbort(prompt, `${label}${choices}${field.default !== undefined ? ` (default ${field.default})` : ''}: `, controller.signal);
|
|
809
|
+
if (value === '' && field.default !== undefined) value = field.default;
|
|
810
|
+
}
|
|
811
|
+
if ((value === '' || value == null) && field.required) throw new Error(`${title} is required.`);
|
|
812
|
+
if ((value === '' || value == null) && field.type === 'boolean') answer[field.key] = field.default ?? false;
|
|
813
|
+
else if (value !== '' && value != null) answer[field.key] = normalizeFormValue(field, value);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (method.type === 'external') {
|
|
817
|
+
await client.integration.connect.external({ integrationID: integration.id, methodID: method.id, answer, location }, { signal: controller.signal });
|
|
818
|
+
connected();
|
|
819
|
+
return 0;
|
|
820
|
+
}
|
|
821
|
+
if (method.type === 'key') {
|
|
822
|
+
const secretField = method.form?.find(field => field.type === 'string' && /(?:secret|token|password|api.?key|private.?key)/i.test(`${field.key} ${field.title || ''}`));
|
|
823
|
+
const key = secretField ? answer[secretField.key] : await questionMasked(stdin, stdout, `${integration.name} API key`, controller.signal);
|
|
824
|
+
if (typeof key !== 'string' || !key.trim()) throw new Error('An API key is required.');
|
|
825
|
+
await client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal: controller.signal });
|
|
826
|
+
connected();
|
|
827
|
+
return 0;
|
|
828
|
+
}
|
|
829
|
+
if (method.type !== 'oauth') throw new Error(`Authentication method ${method.type} is not supported by native CLI login.`);
|
|
830
|
+
|
|
831
|
+
const started = await client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, answer, location }, { signal: controller.signal });
|
|
832
|
+
// The host may complete connect despite an abort racing its response.
|
|
833
|
+
// Capture its attempt ID before observing the signal so finally can
|
|
834
|
+
// cancel the server-side flow.
|
|
835
|
+
attempt = started.data;
|
|
836
|
+
controller.signal.throwIfAborted();
|
|
837
|
+
io.log(attempt.instructions);
|
|
838
|
+
io.log(attempt.url);
|
|
839
|
+
if (stdin?.isTTY && stdout?.isTTY) await openBrowserImpl(attempt.url).catch(() => false);
|
|
840
|
+
if (attempt.mode === 'code') {
|
|
841
|
+
const prompt = readlineFactory({ input: stdin, output: stdout });
|
|
842
|
+
let code;
|
|
843
|
+
code = await questionWithAbort(prompt, 'Paste the authorization code: ', controller.signal);
|
|
844
|
+
if (!code.trim()) throw new Error('Authorization code is required.');
|
|
845
|
+
await client.integration.oauth.complete({ integrationID: integration.id, attemptID: attempt.attemptID, code: code.trim(), location }, { signal: controller.signal });
|
|
846
|
+
connected();
|
|
847
|
+
return 0;
|
|
848
|
+
}
|
|
849
|
+
io.log('Waiting for authorization… (Ctrl+C to cancel)');
|
|
850
|
+
while (!controller.signal.aborted && Date.now() < attempt.time.expires) {
|
|
851
|
+
const response = await client.integration.oauth.status({ integrationID: integration.id, attemptID: attempt.attemptID, location }, { signal: controller.signal });
|
|
852
|
+
const status = response.data;
|
|
853
|
+
if (status.status === 'complete') { connected(); return 0; }
|
|
854
|
+
if (status.status === 'failed') throw new Error(status.message || 'Authorization failed.');
|
|
855
|
+
if (status.status === 'expired') throw new Error('Authorization expired.');
|
|
856
|
+
await new Promise((resolve, reject) => {
|
|
857
|
+
if (controller.signal.aborted) return reject(controller.signal.reason);
|
|
858
|
+
const finish = callback => value => {
|
|
859
|
+
clearTimeout(timer);
|
|
860
|
+
controller.signal.removeEventListener('abort', abort);
|
|
861
|
+
callback(value);
|
|
862
|
+
};
|
|
863
|
+
const timer = setTimeout(finish(resolve), 500);
|
|
864
|
+
const abort = () => finish(reject)(controller.signal.reason);
|
|
865
|
+
controller.signal.addEventListener('abort', abort, { once: true });
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
869
|
+
throw new Error('Authorization expired.');
|
|
870
|
+
} finally {
|
|
871
|
+
process.removeListener('SIGINT', onInterrupt);
|
|
872
|
+
if (attempt && !completed) await client.integration.oauth.cancel({ integrationID: integration.id, attemptID: attempt.attemptID, location }, { signal: AbortSignal.timeout(5_000) }).catch(() => {});
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function shipCredential() {
|
|
877
|
+
const { client } = await serviceClient();
|
|
878
|
+
const { Carthagent } = native.rpcDefinitions ?? await nativeRpcDefinitions();
|
|
879
|
+
return { client, rpc: client.rpc(Carthagent) };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function archiveRequest(method, input = {}) {
|
|
883
|
+
const { client } = await serviceClient();
|
|
884
|
+
const { Archive } = native.rpcDefinitions ?? await nativeRpcDefinitions();
|
|
885
|
+
return client.rpc(Archive)[method](input, { location: { directory: cwd } });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function archive({ args = [] } = {}) {
|
|
889
|
+
const [action = 'list', ...rest] = args;
|
|
890
|
+
if (action === 'list' || action === 'search') {
|
|
891
|
+
const query = action === 'search' ? rest.shift() : undefined;
|
|
892
|
+
if (action === 'search' && !query) throw Object.assign(new Error('Usage: ctg archive search <query> [--json]'), { exitCode: 2 });
|
|
893
|
+
const json = rest.includes('--json');
|
|
894
|
+
const pages = [];
|
|
895
|
+
let offset = 0;
|
|
896
|
+
do {
|
|
897
|
+
const page = await archiveRequest(action, { ...(query ? { query } : {}), offset, limit: 100 });
|
|
898
|
+
pages.push(...page.items);
|
|
899
|
+
offset += page.items.length;
|
|
900
|
+
if (!page.items.length || offset >= page.total) break;
|
|
901
|
+
} while (true);
|
|
902
|
+
if (json) io.log(JSON.stringify(pages));
|
|
903
|
+
else if (!pages.length) io.log(action === 'search' ? `No archived sessions match "${query}".` : 'No archived sessions yet. Finished native sessions appear here and can be read back with `ctg archive read <id>`.');
|
|
904
|
+
else for (const item of pages) io.log(`${item.id} · ${item.messageCount} messages · ${item.createdAt || 'date unknown'}${item.excerpt ? `\n ${item.excerpt}` : ''}`);
|
|
905
|
+
return 0;
|
|
906
|
+
}
|
|
907
|
+
if (action === 'read' || action === 'export') {
|
|
908
|
+
const id = rest.shift();
|
|
909
|
+
if (!id) throw Object.assign(new Error(`Usage: ctg archive ${action} <id>${action === 'export' ? ' [--format json|jsonl]' : ''}`), { exitCode: 2 });
|
|
910
|
+
if (action === 'read') {
|
|
911
|
+
const data = await archiveRequest('read', { id });
|
|
912
|
+
io.log(JSON.stringify(data, null, 2));
|
|
913
|
+
return 0;
|
|
914
|
+
}
|
|
915
|
+
let format = 'json';
|
|
916
|
+
for (let index = 0; index < rest.length; index++) {
|
|
917
|
+
if (rest[index] === '--format' && ['json', 'jsonl'].includes(rest[index + 1])) format = rest[++index];
|
|
918
|
+
else throw Object.assign(new Error('Usage: ctg archive export <id> [--format json|jsonl]'), { exitCode: 2 });
|
|
919
|
+
}
|
|
920
|
+
const result = await archiveRequest('export', { id, format });
|
|
921
|
+
stdout.write(`${result.content}${result.content.endsWith('\n') ? '' : '\n'}`);
|
|
922
|
+
return 0;
|
|
923
|
+
}
|
|
924
|
+
throw Object.assign(new Error('Usage: ctg archive [list|search <query>|read <id>|export <id> [--format json|jsonl]]'), { exitCode: 2 });
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function cloudClient(accessToken) {
|
|
928
|
+
const { CloudClient, cloudControlUrl, formatCloudAccount, formatNanoUsd, openBrowser, cloudManagedUsageRecovery } = await import('../cloud/client.mjs');
|
|
929
|
+
return { client: new CloudClient({ baseUrl: cloudControlUrl() }), formatCloudAccount, formatNanoUsd, openBrowser, cloudManagedUsageRecovery, accessToken };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function account({ args = [] } = {}) {
|
|
933
|
+
const auth = await shipCredential();
|
|
934
|
+
let result;
|
|
935
|
+
try { result = (await auth.rpc.shipAccount({}, { location: { directory: cwd } })).account; }
|
|
936
|
+
catch (error) { throw new Error(shipLoginGuidance(error) || `Carthagent Cloud: ${error.message}`); }
|
|
937
|
+
const cloud = await cloudClient('');
|
|
938
|
+
if (args.includes('--json')) io.log(JSON.stringify(result));
|
|
939
|
+
else {
|
|
940
|
+
io.log(cloud.formatCloudAccount(result));
|
|
941
|
+
io.log('\nCredit grants:');
|
|
942
|
+
for (const grant of result.grants || []) io.log(` ${cloud.formatNanoUsd(grant.remainingNanoUsd)} remaining of ${cloud.formatNanoUsd(grant.originalNanoUsd)} · ${grant.source}${grant.expiresAt ? ` · expires ${grant.expiresAt}` : ''}`);
|
|
943
|
+
io.log('\nCLI sessions:');
|
|
944
|
+
for (const session of result.sessions || []) io.log(` ${session.id} · ${session.revoked_at ? 'revoked' : 'active'} · ${session.client_name || 'Carthagent CLI'} · last used ${session.last_used_at || session.created_at}`);
|
|
945
|
+
}
|
|
946
|
+
return 0;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function billing({ action, plan }) {
|
|
950
|
+
if (action === 'refresh') {
|
|
951
|
+
const result = await account();
|
|
952
|
+
io.log('Return to your existing session. When usage is available, explicitly continue the task.');
|
|
953
|
+
return result;
|
|
954
|
+
}
|
|
955
|
+
const auth = await shipCredential();
|
|
956
|
+
let result;
|
|
957
|
+
try {
|
|
958
|
+
if (action === 'checkout') {
|
|
959
|
+
const { quote } = await auth.rpc.shipBilling({ action: 'quote', ...(plan ? { plan } : {}) }, { location: { directory: cwd } });
|
|
960
|
+
io.log(quote.terms);
|
|
961
|
+
if (quote.changingPlan) io.log('Review the exact proration in your browser before confirming the change.');
|
|
962
|
+
}
|
|
963
|
+
result = await auth.rpc.shipBilling({ action, ...(plan ? { plan } : {}) }, { location: { directory: cwd } });
|
|
964
|
+
}
|
|
965
|
+
catch (error) { throw new Error(shipLoginGuidance(error) || `Carthagent Cloud: ${error.message}`); }
|
|
966
|
+
const opened = await openBrowserImpl(result.url).catch(() => false);
|
|
967
|
+
io.log(`${opened ? 'Opened' : 'Open'} ${result.url}`);
|
|
968
|
+
io.log('After payment, run ctg billing refresh. Return to your existing session and explicitly continue the task.');
|
|
969
|
+
return 0;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function logout({ session: sessionID }) {
|
|
973
|
+
const auth = await shipCredential();
|
|
974
|
+
let result;
|
|
975
|
+
try { result = await auth.rpc.shipLogout({ ...(sessionID ? { session: sessionID } : {}) }, { location: { directory: cwd } }); }
|
|
976
|
+
catch (error) { throw new Error(shipLoginGuidance(error) || `Carthagent Cloud logout: ${error.message}`); }
|
|
977
|
+
if (!sessionID && result.credentialID) {
|
|
978
|
+
await auth.client.credential.remove({ credentialID: result.credentialID });
|
|
979
|
+
}
|
|
980
|
+
io.log(sessionID ? `Revoked Cloud session ${sessionID}.` : 'Revoked and removed the current Carthagent Cloud session.');
|
|
981
|
+
return 0;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function review({ args }) {
|
|
985
|
+
if (!args.length || (args.length === 1 && args[0] === 'status')) {
|
|
986
|
+
const config = loadCarthagentConfig();
|
|
987
|
+
io.log(`self-review default: ${resolveReviewMode(undefined, config)} (config: ${config.review ?? 'unset'} @ ${carthagentConfigPath()})`);
|
|
988
|
+
return 0;
|
|
989
|
+
}
|
|
990
|
+
const mode = args.length === 1 ? normalizeReviewMode(args[0]) : null;
|
|
991
|
+
if (mode) { saveCarthagentConfig({ review: mode }); io.log(`self-review default → ${mode} (saved to ${carthagentConfigPath()})`); return 0; }
|
|
992
|
+
let timeoutMs = 300_000;
|
|
993
|
+
let target;
|
|
994
|
+
let requestFile;
|
|
995
|
+
for (let index = 0; index < args.length; index++) {
|
|
996
|
+
const value = args[index];
|
|
997
|
+
if (value === '--timeout') {
|
|
998
|
+
const seconds = Number(args[++index]);
|
|
999
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 300) throw Object.assign(new Error('Review timeout must be an integer from 1 to 300'), { exitCode: 2 });
|
|
1000
|
+
timeoutMs = seconds * 1000;
|
|
1001
|
+
} else if (value === '--request-file') {
|
|
1002
|
+
if (requestFile || !args[index + 1] || args[index + 1].startsWith('-')) throw Object.assign(new Error('Use ctg review workspace --request-file <original-task.txt>'), { exitCode: 2 });
|
|
1003
|
+
requestFile = args[++index];
|
|
1004
|
+
} else if (value.startsWith('-') || target !== undefined) {
|
|
1005
|
+
throw Object.assign(new Error(`Invalid review argument: ${value}. Use ctg review [status|ask|yes|no|workspace --request-file <file>|<PR number or URL>] [--timeout 1..300]`), { exitCode: 2 });
|
|
1006
|
+
} else target = value;
|
|
1007
|
+
}
|
|
1008
|
+
let reviewTask;
|
|
1009
|
+
if (target === 'workspace') {
|
|
1010
|
+
if (!requestFile) throw Object.assign(new Error('Use ctg review workspace --request-file <original-task.txt>'), { exitCode: 2 });
|
|
1011
|
+
const file = resolve(cwd, requestFile);
|
|
1012
|
+
try {
|
|
1013
|
+
if (!(await stat(file)).isFile()) throw new Error('not a regular file');
|
|
1014
|
+
reviewTask = await readFile(file, 'utf8');
|
|
1015
|
+
} catch {
|
|
1016
|
+
throw Object.assign(new Error(`Review request file is not a readable regular file: ${requestFile}`), { exitCode: 2 });
|
|
1017
|
+
}
|
|
1018
|
+
} else if (requestFile) {
|
|
1019
|
+
throw Object.assign(new Error('--request-file is only valid with `ctg review workspace`.'), { exitCode: 2 });
|
|
1020
|
+
} else if (target !== undefined && !/^(?:[1-9]\d*|https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9]\d*\/?)$/.test(target)) {
|
|
1021
|
+
throw Object.assign(new Error(`Invalid review target: ${target}. Use workspace, a positive PR number, or a GitHub pull-request URL.`), { exitCode: 2 });
|
|
1022
|
+
} else if (target === undefined) {
|
|
1023
|
+
throw Object.assign(new Error('Review target is required. Use workspace, a positive PR number, or a GitHub pull-request URL.'), { exitCode: 2 });
|
|
1024
|
+
}
|
|
1025
|
+
const { client } = await serviceClient();
|
|
1026
|
+
const { startReview, startPullRequestReview } = await import(pathToFileURL(join(runtimeRoot, 'lib/opencode/review.mjs')).href);
|
|
1027
|
+
const ctx = {
|
|
1028
|
+
session: {
|
|
1029
|
+
create: (input, options) => client.session.create({
|
|
1030
|
+
title: input.title,
|
|
1031
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
1032
|
+
...(input.model ? { model: input.model } : {}),
|
|
1033
|
+
location: input.location || { directory: cwd },
|
|
1034
|
+
...(input.permissions ? { permissions: input.permissions } : {}),
|
|
1035
|
+
}, options),
|
|
1036
|
+
prompt: (input, options) => client.session.prompt({
|
|
1037
|
+
sessionID: input.sessionID,
|
|
1038
|
+
text: reviewTask ? `${input.text}\n\nOriginal task to use as review criteria:\n${reviewTask}` : input.text,
|
|
1039
|
+
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
1040
|
+
}, options),
|
|
1041
|
+
wait: (input, options) => client.session.wait(input, options),
|
|
1042
|
+
context: (input, options) => client.session.context(input, options),
|
|
1043
|
+
get: (input, options) => client.session.get(input, options),
|
|
1044
|
+
interrupt: (input, options) => client.session.interrupt(input, options),
|
|
1045
|
+
},
|
|
1046
|
+
agent: { list: async (_input, options) => {
|
|
1047
|
+
await client.location.get({ location: { directory: cwd } }, options);
|
|
1048
|
+
// Model inventory awaits native plugin activation; agent inventory does not.
|
|
1049
|
+
await client.model.list({ location: { directory: cwd } }, options);
|
|
1050
|
+
return client.agent.list({ location: { directory: cwd } }, options);
|
|
1051
|
+
} },
|
|
1052
|
+
};
|
|
1053
|
+
if (target === 'workspace') {
|
|
1054
|
+
const result = await startReview({ ctx, cwd, identity: `workspace:${cwd}`, timeoutMs });
|
|
1055
|
+
io.log(`VERDICT: ${result.verdict}`);
|
|
1056
|
+
return result.verdict === 'APPROVE' ? 0 : 1;
|
|
1057
|
+
}
|
|
1058
|
+
if (target !== undefined) {
|
|
1059
|
+
const view = await runCommand(['gh', 'pr', 'view', target, '--json', 'url,number'], { cwd, timeoutSeconds: 30 });
|
|
1060
|
+
if (view.code !== 0) throw Object.assign(new Error(view.output.trim() || `Cannot load PR ${target}; verify gh authentication and repository.`), { exitCode: 2 });
|
|
1061
|
+
const meta = JSON.parse(view.output);
|
|
1062
|
+
const remote = await runCommand(['git', 'remote', 'get-url', 'origin'], { cwd, timeoutSeconds: 10 });
|
|
1063
|
+
if (remote.code !== 0) throw Object.assign(new Error(remote.output.trim() || 'Cannot determine the GitHub repository from origin'), { exitCode: 2 });
|
|
1064
|
+
const repository = remote.output.trim()
|
|
1065
|
+
.replace(/^git@github\.com:/, '').replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '').replace(/\/$/, '');
|
|
1066
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw Object.assign(new Error('Origin is not a supported GitHub owner/repository URL'), { exitCode: 2 });
|
|
1067
|
+
const outcome = await startPullRequestReview({ ctx, cwd, repository, prNumber: Number(meta.number), timeoutMs });
|
|
1068
|
+
io.log(`VERDICT: ${outcome.verdict}`);
|
|
1069
|
+
return outcome.verdict === 'APPROVE' ? 0 : 1;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async function domain({ command, args = [] }) {
|
|
1074
|
+
if (['s', 'fingerprint', 'c'].includes(command)) command = ({ s: 'status', fingerprint: 'hash', c: 'check' })[command];
|
|
1075
|
+
if (command === 'status') {
|
|
1076
|
+
const currentFp = fingerprint(cwd, ['.']);
|
|
1077
|
+
const report = latestReport(cwd);
|
|
1078
|
+
if (args.includes('--json')) {
|
|
1079
|
+
const state = report?.state;
|
|
1080
|
+
io.log(JSON.stringify({ version: 1, workspace: cwd, currentFingerprint: currentFp, report: report?.path || null, state: state || null, planningView: planningView(state, currentFp), completionIssues: state?.plan ? completionIssues(state, cwd, currentFp) : null }));
|
|
1081
|
+
return 0;
|
|
1082
|
+
}
|
|
1083
|
+
io.log(`Workspace: ${cwd}`);
|
|
1084
|
+
io.log(`Fingerprint: ${currentFp}`);
|
|
1085
|
+
if (!report?.state?.plan) { io.log('No delivery report found for this workspace.'); return 0; }
|
|
1086
|
+
const { state } = report;
|
|
1087
|
+
io.log(`Status: ${state.status}`);
|
|
1088
|
+
io.log(`Goal: ${state.plan.goal}`);
|
|
1089
|
+
io.log(`Checks: ${state.plan.checks.map(check => {
|
|
1090
|
+
const evidence = state.evidence?.[check.id];
|
|
1091
|
+
if (!evidence) return `pending:${check.id}`;
|
|
1092
|
+
if (!evidence.passed) return `failed:${check.id}`;
|
|
1093
|
+
return `${evidenceFresh(check, evidence, currentFp, state) ? 'passed' : 'stale'}:${check.id}`;
|
|
1094
|
+
}).join(', ')}`);
|
|
1095
|
+
io.log(`Report: ${report.path}`);
|
|
1096
|
+
return 0;
|
|
1097
|
+
}
|
|
1098
|
+
if (command === 'hash') {
|
|
1099
|
+
io.log(fingerprint(cwd, args.length ? args : ['.']));
|
|
1100
|
+
return 0;
|
|
1101
|
+
}
|
|
1102
|
+
if (command === 'validate') {
|
|
1103
|
+
if (!args[0]) throw new Error('Usage: ctg validate <path-to-plan.json>');
|
|
1104
|
+
const plan = JSON.parse(await readFile(resolve(cwd, args[0]), 'utf8'));
|
|
1105
|
+
const validated = validatePlan(plan, cwd);
|
|
1106
|
+
io.log('✓ Acceptance contract is VALID.');
|
|
1107
|
+
io.log(`Goal: ${validated.goal}`);
|
|
1108
|
+
io.log(`Checks: ${validated.checks.map(check => check.id).join(', ')}`);
|
|
1109
|
+
io.log(`\nGenerated D2 Outline:\n${planD2(validated)}`);
|
|
1110
|
+
return 0;
|
|
1111
|
+
}
|
|
1112
|
+
if (command === 'check') {
|
|
1113
|
+
const release = lockWorkspace(cwd);
|
|
1114
|
+
try {
|
|
1115
|
+
const report = latestReport(cwd);
|
|
1116
|
+
if (!report?.state?.plan) { io.error('No delivery report found for this workspace.'); return 1; }
|
|
1117
|
+
selectReport(cwd, report.path);
|
|
1118
|
+
const state = report.state;
|
|
1119
|
+
const checkID = args[0] || 'all';
|
|
1120
|
+
const checks = checkID === 'all' ? state.plan.checks : state.plan.checks.filter(check => check.id === checkID);
|
|
1121
|
+
if (!checks.length) { io.error(`Unknown check ID: ${checkID}`); return 1; }
|
|
1122
|
+
startImplementation(state, fingerprint(cwd, ['.']));
|
|
1123
|
+
let allPassed = true;
|
|
1124
|
+
const priorStatus = state.status;
|
|
1125
|
+
state.status = 'verifying'; state.verificationStarted = true; saveReport(report.path, state);
|
|
1126
|
+
for (const check of checks) {
|
|
1127
|
+
const before = fingerprint(cwd, ['.']);
|
|
1128
|
+
io.log(`\n${check.id} ${check.argv.join(' ')}`);
|
|
1129
|
+
const result = await runCommand(check.argv, { cwd, timeoutSeconds: check.timeoutSeconds, logPath: join(report.dir, `${check.id}-${randomUUID()}.log`) });
|
|
1130
|
+
const after = fingerprint(cwd, ['.']);
|
|
1131
|
+
const passed = result.code === 0 && !result.timedOut && !result.cancelled && !result.outputLimit && before === after;
|
|
1132
|
+
state.evidence[check.id] = { passed, fingerprint: after, executedRevision: state.revision, code: result.code, timedOut: result.timedOut, cancelled: result.cancelled, outputLimit: result.outputLimit, changedDuringCheck: before !== after, durationMs: result.durationMs, logPath: result.logPath, outputTail: result.output.slice(-1200) };
|
|
1133
|
+
saveReport(report.path, state);
|
|
1134
|
+
if (result.output) io.log(result.output.trim());
|
|
1135
|
+
io.log(passed ? `passed (${result.durationMs}ms)` : `failed (exit ${result.code})`);
|
|
1136
|
+
allPassed &&= passed;
|
|
1137
|
+
}
|
|
1138
|
+
const complete = Object.values(completionIssues(state, cwd, fingerprint(cwd, ['.']))).every(items => items.length === 0);
|
|
1139
|
+
state.status = allPassed && complete && priorStatus === 'verified' ? 'verified' : 'implementing';
|
|
1140
|
+
saveReport(report.path, state);
|
|
1141
|
+
return allPassed ? 0 : 1;
|
|
1142
|
+
} finally { release(); }
|
|
1143
|
+
}
|
|
1144
|
+
throw new Error(`Unsupported native domain command: ${command}`);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async function command({ command, args }) {
|
|
1148
|
+
if (command === 'service') {
|
|
1149
|
+
if (args.length !== 1 || args[0] !== 'stop') throw Object.assign(new Error('Usage: ctg native service stop'), { exitCode: 2 });
|
|
1150
|
+
const stopped = await serviceApi.stopService(runtimeOptions);
|
|
1151
|
+
io.log(stopped ? 'Stopped the registered native service. Sessions and credentials were preserved.' : 'No registered native service is running.');
|
|
1152
|
+
return 0;
|
|
1153
|
+
}
|
|
1154
|
+
if (command === 'demo') {
|
|
1155
|
+
const { runOfflineDemo } = await import('./demo.mjs');
|
|
1156
|
+
return runOfflineDemo({ io, nodeBinary: process.env.CTG_NODE_EXECUTABLE || process.execPath, ...(native.demoExecute ? { execute: native.demoExecute } : {}) });
|
|
1157
|
+
}
|
|
1158
|
+
if (command === 'test') return runCommand([process.execPath, '--test', 'tests/*.test.mjs'], { cwd: runtimeRoot, timeoutSeconds: 600 }).then(result => result.code);
|
|
1159
|
+
if (command === 'eval') return runCommand([process.execPath, join(runtimeRoot, 'evaluate.mjs'), ...args], { cwd: runtimeRoot, timeoutSeconds: 600 }).then(result => result.code);
|
|
1160
|
+
if (command === 'serve') {
|
|
1161
|
+
const child = spawn(process.execPath, [join(runtimeRoot, 'server.mjs')], { cwd: runtimeRoot, env: withoutRuntimeGithubToken({ ...process.env, CARTHAGENT_WORKSPACE: cwd }), stdio: 'inherit' });
|
|
1162
|
+
return childExit(child);
|
|
1163
|
+
}
|
|
1164
|
+
if (command === 'update') {
|
|
1165
|
+
const updateModule = native.updateModule ?? await import('./update.mjs');
|
|
1166
|
+
const updateApi = native.updateApi ?? {};
|
|
1167
|
+
const packageRoot = modules.packageRoot || runtimeRoot;
|
|
1168
|
+
await updateModule.runNativeUpdate({
|
|
1169
|
+
args, io, cwd: packageRoot, root: packageRoot,
|
|
1170
|
+
...(updateApi.packageVersion ? { currentVersion: updateApi.packageVersion() } : {}),
|
|
1171
|
+
...(updateApi.fetchLatestRelease ? { fetchLatestRelease: updateApi.fetchLatestRelease } : {}),
|
|
1172
|
+
...(updateApi.detectInstallMethod ? { detectInstallMethod: updateApi.detectInstallMethod } : {}),
|
|
1173
|
+
...((native.runCommand || updateApi.runCommand) ? { runCommand: native.runCommand || updateApi.runCommand } : {}),
|
|
1174
|
+
});
|
|
1175
|
+
return 0;
|
|
1176
|
+
}
|
|
1177
|
+
if (command === 'quit' || command === 'q' || command === 'exit') {
|
|
1178
|
+
if (args.length) throw Object.assign(new Error(`Usage: ctg ${command}`), { exitCode: 2 });
|
|
1179
|
+
io.log('There is no active native CLI session to quit.');
|
|
1180
|
+
return 0;
|
|
1181
|
+
}
|
|
1182
|
+
throw new Error(`Native command ${command} is not wired to a native implementation yet`);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
return { run, listModels, login, account, billing, logout, archive, review, domain, command };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export { hasHeadlessTerminal, headlessSucceeded, latestSessionForWorkspace, modelWithThinking, nativeJsonEvents, providerModel, reconcileHeadlessResult, textParts };
|