Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions REMOTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,43 @@ After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint.

SSH launch is a desktop feature because it needs local process and SSH access. Once the environment is paired and saved, it uses the same environment list and connection model as direct LAN, Tailscale, HTTPS, or future tunnel-backed environments.

#### SSH Launch Troubleshooting

The desktop SSH launcher connects with a non-interactive `sh` session, writes a small launcher script under `~/.t3/ssh-launch/<host-key>/`, starts or reuses a remote T3 server, and forwards the remote loopback port back to your desktop.

The remote host must have a compatible Node.js runtime. T3 Code uses the server package's `engines.node` requirement:

```text
^22.16 || ^23.11 || >=24.10
```

During SSH launch, T3 Code first checks whether `node` is already available on `PATH`. If it is missing, the launcher tries common non-interactive shell locations and version-manager shims/activation hooks:

- `~/.local/bin`, `~/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
- Volta via `~/.volta/bin`
- asdf via `~/.asdf/shims`, `~/.asdf/bin`, or `~/.asdf/asdf.sh`
- mise via `~/.local/share/mise/shims`, `~/.mise/shims`, or `mise activate sh`
- fnm via `fnm env --use-on-cd --shell sh` or `fnm env --shell sh`
- nodenv via `~/.nodenv/bin`, `~/.nodenv/shims`, or `nodenv init -`
- nvm via `$NVM_DIR/nvm.sh`, then `nvm use default`, `nvm use node`, or `nvm use --lts`
- installed nvm versions under `$NVM_DIR/versions/node/*/bin`

If launch fails with `node: command not found`, a port-scan failure, or a message that the remote Node version does not satisfy the required range, SSH into the host and check the same non-interactive shell path T3 Code uses:

```bash
ssh user@example.com 'sh -lc "command -v node && node --version"'
```

If that does not print a compatible Node version, configure your version manager for non-interactive shells or install a compatible Node binary in one of the searched locations. For example, with nvm you may need a default alias:

```bash
nvm alias default 24
```

With mise/asdf/fnm/nodenv, make sure the tool's shim directory is installed and points at a Node version satisfying the range above.

If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually.

## How Pairing Works

The remote device does not need a long-lived secret up front.
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Electron from "electron";
import * as NetService from "@t3tools/shared/Net";
import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
import serverPackageJson from "../../server/package.json" with { type: "json" };

import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts";
import * as DesktopIpc from "./ipc/DesktopIpc.ts";
Expand Down Expand Up @@ -65,14 +66,18 @@ const resolveDesktopSshCliRunner = (
): RemoteT3RunnerOptions => {
const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);
if (environment.isDevelopment && devRemoteEntryPath !== undefined) {
return { nodeScriptPath: devRemoteEntryPath };
return {
nodeScriptPath: devRemoteEntryPath,
nodeEngineRange: serverPackageJson.engines.node,
};
}
return {
packageSpec: resolveRemoteT3CliPackageSpec({
appVersion: environment.appVersion,
updateChannel: settings.updateChannel,
isDevelopment: environment.isDevelopment,
}),
nodeEngineRange: serverPackageJson.engines.node,
};
};

Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getProviderOptionCurrentValue,
getProviderOptionDescriptors,
} from "@t3tools/shared/model";
import { compareSemverVersions } from "@t3tools/shared/semver";
import {
query as claudeQuery,
type SlashCommand as ClaudeSlashCommand,
Expand All @@ -36,7 +37,6 @@ import {
spawnAndCollect,
type ServerProviderDraft,
} from "../providerSnapshot.ts";
import { compareCliVersions } from "../cliVersion.ts";
import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";

const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
Expand Down Expand Up @@ -180,7 +180,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
];

function supportsClaudeOpus47(version: string | null | undefined): boolean {
return version ? compareCliVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false;
return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false;
}

function getBuiltInClaudeModelsForVersion(
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/provider/Layers/OpenCodeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";

import { createModelCapabilities } from "@t3tools/shared/model";
import { compareSemverVersions } from "@t3tools/shared/semver";
import {
buildServerProvider,
nonEmptyTrimmed,
parseGenericCliVersion,
providerModelsFromSettings,
type ServerProviderDraft,
} from "../providerSnapshot.ts";
import { compareCliVersions } from "../cliVersion.ts";
import {
OpenCodeRuntime,
openCodeRuntimeErrorDetail,
Expand Down Expand Up @@ -383,7 +383,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
null,
);
}
if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) {
if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) {
return buildServerProvider({
presentation: OPENCODE_PRESENTATION,
enabled: openCodeSettings.enabled,
Expand Down
17 changes: 0 additions & 17 deletions apps/server/src/provider/cliVersion.test.ts

This file was deleted.

123 changes: 0 additions & 123 deletions apps/server/src/provider/cliVersion.ts

This file was deleted.

5 changes: 2 additions & 3 deletions apps/server/src/provider/providerMaintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type ServerProvider,
type ServerProviderVersionAdvisory,
} from "@t3tools/contracts";
import { compareSemverVersions } from "@t3tools/shared/semver";
import { resolveCommandPath } from "@t3tools/shared/shell";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
Expand All @@ -11,8 +12,6 @@ import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import { HttpClient, HttpClientRequest } from "effect/unstable/http";

import { compareCliVersions } from "./cliVersion.ts";

const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
const LATEST_VERSION_TIMEOUT_MS = 4_000;
const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
Expand Down Expand Up @@ -365,7 +364,7 @@ function deriveVersionAdvisory(input: {
if (!input.latestVersion) {
return { status: "unknown", message: null };
}
if (compareCliVersions(input.currentVersion, input.latestVersion) < 0) {
if (compareSemverVersions(input.currentVersion, input.latestVersion) < 0) {
return {
status: "behind_latest",
message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/textGeneration/CursorTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";

import { type CursorSettings, type ModelSelection } from "@t3tools/contracts";
import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
import { extractJsonObject } from "@t3tools/shared/schemaJson";

import { TextGenerationError } from "@t3tools/contracts";
import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts";
Expand All @@ -16,7 +17,6 @@ import {
buildThreadTitlePrompt,
} from "./TextGenerationPrompts.ts";
import {
extractJsonObject,
sanitizeCommitSubject,
sanitizePrTitle,
sanitizeThreadTitle,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/textGeneration/OpenCodeTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "@t3tools/contracts";
import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
import { extractJsonObject } from "@t3tools/shared/schemaJson";

import { ServerConfig } from "../config.ts";
import { resolveAttachmentPath } from "../attachmentStore.ts";
Expand All @@ -24,7 +25,6 @@ import {
} from "./TextGenerationPrompts.ts";
import { type TextGenerationShape } from "./TextGeneration.ts";
import {
extractJsonObject,
sanitizeCommitSubject,
sanitizePrTitle,
sanitizeThreadTitle,
Expand Down
50 changes: 1 addition & 49 deletions apps/server/src/textGeneration/TextGenerationUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { TextGenerationError } from "@t3tools/contracts";
import * as Schema from "effect/Schema";

import { TextGenerationError } from "@t3tools/contracts";
const isTextGenerationError = Schema.is(TextGenerationError);

/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */
Expand All @@ -19,54 +19,6 @@ export function limitSection(value: string, maxChars: number): string {
return `${truncated}\n\n[truncated]`;
}

export function extractJsonObject(raw: string): string {
const trimmed = raw.trim();
if (trimmed.length === 0) {
return trimmed;
}

const start = trimmed.indexOf("{");
if (start < 0) {
return trimmed;
}

let depth = 0;
let inString = false;
let escaping = false;
for (let index = start; index < trimmed.length; index += 1) {
const char = trimmed[index];
if (inString) {
if (escaping) {
escaping = false;
} else if (char === "\\") {
escaping = true;
} else if (char === '"') {
inString = false;
}
continue;
}

if (char === '"') {
inString = true;
continue;
}

if (char === "{") {
depth += 1;
continue;
}

if (char === "}") {
depth -= 1;
if (depth === 0) {
return trimmed.slice(start, index + 1);
}
}
}

return trimmed.slice(start);
}

/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */
export function sanitizeCommitSubject(raw: string): string {
const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
"types": "./src/shell.ts",
"import": "./src/shell.ts"
},
"./semver": {
"types": "./src/semver.ts",
"import": "./src/semver.ts"
},
"./Net": {
"types": "./src/Net.ts",
"import": "./src/Net.ts"
Expand Down
Loading
Loading