@timlaing/opencode-tunnel-switch 0.0.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +85 -28
- package/index.ts +2 -0
- package/package.json +51 -7
- package/src/client.ts +82 -0
- package/src/discovery.ts +27 -0
- package/src/environment.ts +55 -0
- package/src/index.ts +68 -0
- package/src/status.ts +63 -0
- package/src/tui.ts +226 -0
- package/tui.ts +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tim Laing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,45 +1,102 @@
|
|
|
1
1
|
# @timlaing/opencode-tunnel-switch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode v2 plugin that discovers the Python service's HTTP proxy, configures proxy environment variables for the OpenCode server process, and rotates an SSH SOCKS tunnel when a configured provider reports exhausted free usage. `/tunnel-status` displays the current connection ID and start time without a model request.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The external [ssh-tunnel-switch](https://github.com/timlaing/ssh-tunnel-switch) server, available on [PyPI](https://pypi.org/project/ssh-tunnel-switch/), must be running before plugin startup. See the repository [setup guide](https://github.com/timlaing/opencode-tunnel-switch#readme).
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
After a successful tunnel switch, the plugin requests that OpenCode retry the current model request after one second, continuing the existing work automatically. Status display runs independently so a slow or failed notification cannot delay continuation. If a switch returns 429 (for example, another switch is already in progress), only the current work request is retried after `Retry-After`; the switch is not retried in that retry sequence. A local cooldown similarly delays the work request until it expires. Other switch failures stop the retry, and OpenCode's retry limit still applies.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
The OpenCode session sidebar also includes a **Tunnel** section with connection
|
|
10
|
+
state, server, start time, uptime, proxy address and API availability. While
|
|
11
|
+
collapsed, the header still names the tunnel and its reachability, for example
|
|
12
|
+
`▶ Tunnel (fra1 · reachable)`. It refreshes every five seconds;
|
|
13
|
+
`/tunnel-refresh` refreshes immediately. Failed checks mark previous details
|
|
14
|
+
stale. Reload OpenCode after updating the plugin.
|
|
15
|
+
The TUI uses the same API options from the terminal machine, so remote-server
|
|
16
|
+
setups need an API address reachable from that machine.
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
OpenCode's TUI runtime does not receive plugin options from `opencode.json(c)`;
|
|
19
|
+
it only receives options declared in the terminal's `cli.json`
|
|
20
|
+
(`~/.config/opencode/cli.json`). Declare the same options there to enable the
|
|
21
|
+
sidebar; without that declaration the sidebar degrades to one error toast while
|
|
22
|
+
rotation and `/tunnel-status` keep working through the server plugin:
|
|
15
23
|
|
|
16
|
-
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"plugins": [
|
|
27
|
+
{
|
|
28
|
+
"package": "@timlaing/opencode-tunnel-switch@latest",
|
|
29
|
+
"options": {
|
|
30
|
+
"apiIp": "127.0.0.1",
|
|
31
|
+
"apiPort": 8765,
|
|
32
|
+
"apiToken": "copy-the-token-displayed-by-the-python-service"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
```
|
|
17
38
|
|
|
18
|
-
|
|
39
|
+
## Install
|
|
19
40
|
|
|
20
|
-
|
|
41
|
+
The same archive is published to the public npm registry and to GitHub Packages.
|
|
21
42
|
|
|
22
|
-
|
|
43
|
+
### npm
|
|
23
44
|
|
|
24
|
-
|
|
25
|
-
2. Configure the trusted publisher (e.g., GitHub Actions)
|
|
26
|
-
3. Specify the repository and workflow that should be allowed to publish
|
|
27
|
-
4. Use the configured workflow to publish your actual package
|
|
45
|
+
No authentication is required:
|
|
28
46
|
|
|
29
|
-
|
|
47
|
+
```sh
|
|
48
|
+
npm install @timlaing/opencode-tunnel-switch
|
|
49
|
+
```
|
|
30
50
|
|
|
31
|
-
|
|
32
|
-
- Contains no executable code
|
|
33
|
-
- Provides no functionality
|
|
34
|
-
- Should not be installed as a dependency
|
|
35
|
-
- Exists only for administrative purposes
|
|
51
|
+
### GitHub Packages
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
Public npm packages on GitHub Packages still require authentication, as documented by [GitHub](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#authenticating-to-github-packages). Use a GitHub personal access token (classic) with `read:packages`:
|
|
38
54
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
```sh
|
|
56
|
+
npm login --scope=@timlaing --auth-type=legacy --registry=https://npm.pkg.github.com
|
|
57
|
+
```
|
|
42
58
|
|
|
43
|
-
|
|
59
|
+
Add this scope mapping to your user `~/.npmrc` if it is not already present:
|
|
44
60
|
|
|
45
|
-
|
|
61
|
+
```ini
|
|
62
|
+
@timlaing:registry=https://npm.pkg.github.com
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Note that npm resolves a scope mapping ahead of a plain `--registry` flag, so an `@timlaing` mapping routes every `@timlaing/opencode-tunnel-switch` install to GitHub Packages.
|
|
66
|
+
|
|
67
|
+
Keep registry credentials in your npm user configuration, not the repository. Configure OpenCode using the singular `plugin` key and the `[package, options]` tuple:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"$schema": "https://opencode.ai/config.json",
|
|
72
|
+
"plugin": [
|
|
73
|
+
[
|
|
74
|
+
"@timlaing/opencode-tunnel-switch@1.5.0",
|
|
75
|
+
{
|
|
76
|
+
"apiIp": "127.0.0.1",
|
|
77
|
+
"apiPort": 8765,
|
|
78
|
+
"apiToken": "copy-the-token-displayed-by-the-python-service"
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The REST token is separate from GitHub registry credentials. Proxy IP/port are discovered automatically from `/status`. OpenCode's Bun runtime loads the shipped TypeScript root `index.ts`; no consumer build step is required. Local development can still configure the absolute `plugin` directory instead of the package name.
|
|
86
|
+
|
|
87
|
+
## Serializer dependency
|
|
88
|
+
|
|
89
|
+
OpenTUI currently requires a Solid version that constrains Seroval to a vulnerable
|
|
90
|
+
release range. This checkout overrides Seroval to `~1.6.11` and audits that resolved
|
|
91
|
+
tree. npm applies overrides only from the installing root project: applications
|
|
92
|
+
installing this package must also enforce a patched Seroval version in their root
|
|
93
|
+
dependency configuration. This package's override does not patch OpenCode's own
|
|
94
|
+
bundled dependencies.
|
|
95
|
+
|
|
96
|
+
## Publishing
|
|
97
|
+
|
|
98
|
+
The repository's `Publish plugin` workflow runs plugin types and tests, checks the package allowlist, installs the packed tarball and verifies its OpenCode entrypoint in a job that holds no OIDC credentials, then hands that exact archive to a separate publishing job — the only one granted `id-token: write`, and the only one that runs no dependencies and no tests — which uploads it to `https://registry.npmjs.org` and `https://npm.pkg.github.com`.
|
|
99
|
+
|
|
100
|
+
Publishing runs when a GitHub release is published, or manually on `main`. A release tag must be `v<package-version>`, matching `package.json`. Update the manifest and lockfile version before releasing a new version. Published versions cannot be overwritten; a version that already exists on a registry is skipped only when its archive integrity matches the tested tarball, and is rejected when it differs.
|
|
101
|
+
|
|
102
|
+
The npm registry publish authenticates through [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers/): npmjs.com must list this repository and `publish-plugin.yml` as a trusted publisher for the package, and the workflow holds `id-token: write`. No npm token is stored in the repository. Signed provenance is enforced rather than assumed: npm releases publish with `--provenance`, and the readback requires a `dist.attestations` entry for every npm release, so an unattested npm version fails the workflow. Two things carry no attestation by design — the `0.0.0*` placeholder versions created by the one-time trusted-publishing bootstrap (they hold no code, and releases never route through them), and GitHub Packages, which does not support npm provenance attestations. GitHub Packages publishes with the workflow's `GITHUB_TOKEN` and `packages: write`; no publishing PAT is required. The package is linked to the public repository. The manifest requests public access; the workflow checks that both the repository and existing GitHub package are public before publishing. GitHub package visibility is a separate setting: maintainers manage it under [package settings](https://github.com/users/timlaing/packages/npm/opencode-tunnel-switch/settings), rather than relying on npm access flags. The workflow reads the published version back from both registries and verifies its archive integrity and repository link. Do not set the npm `private: true` flag, which prohibits publication.
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,10 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timlaing/opencode-tunnel-switch",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./index.ts",
|
|
7
|
+
"./tui": "./tui.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"check": "tsc --noEmit",
|
|
11
|
+
"test": "node --conditions=browser --import tsx --test test/*.test.ts",
|
|
12
|
+
"test:coverage": "mkdir -p coverage && node --conditions=browser --import tsx --experimental-test-coverage --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info test/*.test.ts",
|
|
13
|
+
"test:tui": "node --conditions=browser --import tsx test/verify-tui.ts",
|
|
14
|
+
"verify:package": "bun scripts/verify-package.ts",
|
|
15
|
+
"publish:package": "node scripts/publish-package.mjs",
|
|
16
|
+
"verify:published": "node scripts/verify-published-package.mjs"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@opencode/plugin": "2.1.4",
|
|
20
|
+
"zod": "^4.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^26.6.5",
|
|
24
|
+
"tsx": "^4.20.0",
|
|
25
|
+
"typescript": "^7.0.2"
|
|
26
|
+
},
|
|
27
|
+
"main": "./index.ts",
|
|
28
|
+
"description": "OpenCode v2 SSH tunnel rotation and proxy discovery plugin",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/timlaing/opencode-tunnel-switch.git",
|
|
32
|
+
"directory": "plugin"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/timlaing/opencode-tunnel-switch#readme",
|
|
35
|
+
"files": [
|
|
36
|
+
"index.ts",
|
|
37
|
+
"tui.ts",
|
|
38
|
+
"src/",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"registry": "https://npm.pkg.github.com",
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@opentui/core": "^0.5.17",
|
|
48
|
+
"@opentui/solid": "^0.5.17",
|
|
49
|
+
"solid-js": "^1.9.12"
|
|
50
|
+
},
|
|
51
|
+
"overrides": {
|
|
52
|
+
"seroval": "~1.6.11"
|
|
53
|
+
}
|
|
10
54
|
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const Options = z.object({
|
|
4
|
+
apiIp: z.union([z.ipv4(), z.ipv6()]).default("127.0.0.1"),
|
|
5
|
+
apiPort: z.number().int().min(1).max(65535).default(8765),
|
|
6
|
+
apiScheme: z.enum(["http", "https"]).default("http"),
|
|
7
|
+
apiToken: z.string().min(1).refine((token) => token.trim().length > 0, "apiToken must not be blank"),
|
|
8
|
+
configureProxy: z.boolean().default(true),
|
|
9
|
+
proxyIp: z.union([z.ipv4(), z.ipv6()]).optional(),
|
|
10
|
+
proxyPort: z.number().int().min(1).max(65535).optional(),
|
|
11
|
+
noProxy: z.array(z.string().min(1)).default([]),
|
|
12
|
+
providers: z.array(z.string().min(1)).min(1).default(["opencode"]),
|
|
13
|
+
patterns: z.array(z.string().min(1)).min(1).default([
|
|
14
|
+
"FreeUsageLimitError", "free usage exceeded", String.raw`rate limit exceeded\. please try again later`,
|
|
15
|
+
]),
|
|
16
|
+
timeoutMs: z.number().int().positive().default(25000),
|
|
17
|
+
cooldownMs: z.number().int().nonnegative().default(30000),
|
|
18
|
+
}).strict();
|
|
19
|
+
|
|
20
|
+
export type OptionsType = z.infer<typeof Options>;
|
|
21
|
+
export type Failure = { type: string; message: string; status?: number };
|
|
22
|
+
/** A rejected rotation whose timeout applies to retrying the current work request. */
|
|
23
|
+
export class RotationRateLimit extends Error {
|
|
24
|
+
/** Carry the wait before retrying work, without requesting another rotation. */
|
|
25
|
+
constructor(message: string, readonly retryAfterMs: number) {
|
|
26
|
+
super(message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Match configured exhaustion errors only for permitted providers and HTTP statuses. */
|
|
30
|
+
export function detector(options: OptionsType) {
|
|
31
|
+
const patterns = options.patterns.map((pattern) => new RegExp(pattern, "i"));
|
|
32
|
+
return (provider: string, error: Failure) => options.providers.includes(provider)
|
|
33
|
+
&& (error.status === undefined || [402, 403, 429].includes(error.status))
|
|
34
|
+
&& patterns.some((pattern) => pattern.test(`${error.type}: ${error.message}`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parse Retry-After as seconds or an HTTP date, retaining a bounded fallback. */
|
|
38
|
+
function retryDelay(header: string | null, cooldownMs: number): number {
|
|
39
|
+
if (header === null) return Math.max(1000, cooldownMs);
|
|
40
|
+
const seconds = /^\d+(?:\.\d+)?$/.test(header.trim()) ? Number(header) : Number.NaN;
|
|
41
|
+
const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(header) - Date.now();
|
|
42
|
+
return Number.isFinite(delay) ? Math.max(1000, delay) : Math.max(1000, cooldownMs);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Create an authenticated control client with shared rotation requests and cooldowns. */
|
|
46
|
+
export function makeClient(options: OptionsType, token: string, fetcher: typeof fetch = fetch) {
|
|
47
|
+
const host = options.apiIp.includes(":") ? `[${options.apiIp}]` : options.apiIp;
|
|
48
|
+
const base = new URL(`${options.apiScheme}://${host}:${options.apiPort}/`);
|
|
49
|
+
let pending: Promise<string> | undefined;
|
|
50
|
+
let lastRotation = -Infinity;
|
|
51
|
+
/** Call the control API directly and expose 429 retry delays to the work retry hook. */
|
|
52
|
+
async function call(path: string, method: string) {
|
|
53
|
+
const init: RequestInit & { proxy: false } = {
|
|
54
|
+
// Bun fetch must reach the control API even while the SSH tunnel is down.
|
|
55
|
+
proxy: false,
|
|
56
|
+
method, headers: { Authorization: `Bearer ${token}` },
|
|
57
|
+
signal: AbortSignal.timeout(options.timeoutMs), redirect: "error",
|
|
58
|
+
};
|
|
59
|
+
const response = await fetcher(new URL(path, base), init);
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const message = `Tunnel API ${response.status}: ${await response.text()}`;
|
|
62
|
+
if (response.status === 429) {
|
|
63
|
+
throw new RotationRateLimit(message, retryDelay(response.headers.get("Retry-After"), options.cooldownMs));
|
|
64
|
+
}
|
|
65
|
+
throw new Error(message);
|
|
66
|
+
}
|
|
67
|
+
return await response.text();
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
/** Read tunnel status without rotating or using the tunnel proxy. */
|
|
71
|
+
status: () => call("status", "GET"),
|
|
72
|
+
/** Attempt one rotation, sharing in-flight calls and reporting remaining cooldowns. */
|
|
73
|
+
rotate: () => {
|
|
74
|
+
if (pending) return pending;
|
|
75
|
+
const remaining = options.cooldownMs - (Date.now() - lastRotation);
|
|
76
|
+
if (remaining > 0) return Promise.reject(new RotationRateLimit("Tunnel rotation cooldown active", remaining));
|
|
77
|
+
lastRotation = Date.now();
|
|
78
|
+
pending = call("rotate", "POST").finally(() => { pending = undefined; });
|
|
79
|
+
return pending;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { OptionsType } from "./client.js";
|
|
3
|
+
|
|
4
|
+
const Status = z.object({
|
|
5
|
+
http_proxy: z.object({
|
|
6
|
+
enabled: z.boolean(),
|
|
7
|
+
ip: z.union([z.ipv4(), z.ipv6()]),
|
|
8
|
+
port: z.number().int().min(1).max(65535),
|
|
9
|
+
}),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function discoverProxy(options: OptionsType, response: string): { proxyIp: string; proxyPort: number } {
|
|
13
|
+
let status: z.infer<typeof Status>;
|
|
14
|
+
try {
|
|
15
|
+
status = Status.parse(JSON.parse(response));
|
|
16
|
+
} catch {
|
|
17
|
+
throw new Error("Proxy discovery failed: /status must contain valid http_proxy enabled, ip and port fields");
|
|
18
|
+
}
|
|
19
|
+
if (!status.http_proxy.enabled)
|
|
20
|
+
throw new Error("Proxy discovery failed: the service HTTP proxy is disabled");
|
|
21
|
+
const bindIp = status.http_proxy.ip;
|
|
22
|
+
const advertisedIp = bindIp === "0.0.0.0" || bindIp === "::" ? options.apiIp : bindIp;
|
|
23
|
+
const proxyIp = options.proxyIp ?? advertisedIp;
|
|
24
|
+
if (proxyIp === "0.0.0.0" || proxyIp === "::")
|
|
25
|
+
throw new Error("Proxy discovery failed: proxyIp must be a reachable address, not a wildcard bind address");
|
|
26
|
+
return { proxyIp, proxyPort: options.proxyPort ?? status.http_proxy.port };
|
|
27
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { OptionsType } from "./client.js";
|
|
2
|
+
|
|
3
|
+
const keys = ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"] as const;
|
|
4
|
+
type Variables = Partial<Record<typeof keys[number], string>>;
|
|
5
|
+
type Lease = { proxy: string; bypass: string[] };
|
|
6
|
+
type State = { baseline: Variables; applied: Variables; leases: Lease[] };
|
|
7
|
+
const states = new WeakMap<NodeJS.ProcessEnv, State>();
|
|
8
|
+
|
|
9
|
+
function values(state: State): Variables {
|
|
10
|
+
const top = state.leases.at(-1);
|
|
11
|
+
if (!top) return state.baseline;
|
|
12
|
+
const bypass = new Set([
|
|
13
|
+
...(state.baseline.NO_PROXY ?? "").split(/[\s,]+/),
|
|
14
|
+
...(state.baseline.no_proxy ?? "").split(/[\s,]+/),
|
|
15
|
+
...state.leases.flatMap((lease) => lease.bypass),
|
|
16
|
+
].filter(Boolean));
|
|
17
|
+
return Object.fromEntries(keys.map((key) => [key,
|
|
18
|
+
key.toLowerCase() === "no_proxy" ? [...bypass].join(",") : top.proxy]));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Leases prevent unloading one project plugin from breaking another in the same process. */
|
|
22
|
+
export function configureProcessProxy(options: Omit<OptionsType, "proxyPort"> & { proxyPort: number }, environment: NodeJS.ProcessEnv = process.env): () => void {
|
|
23
|
+
if (!options.configureProxy) return () => {};
|
|
24
|
+
let state = states.get(environment);
|
|
25
|
+
if (!state) {
|
|
26
|
+
const baseline = Object.fromEntries(keys.map((key) => [key, environment[key]]));
|
|
27
|
+
state = { baseline, applied: baseline, leases: [] };
|
|
28
|
+
states.set(environment, state);
|
|
29
|
+
}
|
|
30
|
+
const ip = options.proxyIp ?? options.apiIp;
|
|
31
|
+
const host = ip.includes(":") ? `[${ip}]` : ip;
|
|
32
|
+
const lease: Lease = {
|
|
33
|
+
proxy: `http://${host}:${options.proxyPort}`,
|
|
34
|
+
bypass: ["localhost", "127.0.0.1", "::1", options.apiIp, ...options.noProxy],
|
|
35
|
+
};
|
|
36
|
+
state.leases.push(lease);
|
|
37
|
+
state.applied = values(state);
|
|
38
|
+
for (const key of keys) environment[key] = state.applied[key];
|
|
39
|
+
let released = false;
|
|
40
|
+
return () => {
|
|
41
|
+
if (released) return;
|
|
42
|
+
released = true;
|
|
43
|
+
const previous = state.applied;
|
|
44
|
+
state.leases.splice(state.leases.indexOf(lease), 1);
|
|
45
|
+
const next = values(state);
|
|
46
|
+
for (const key of keys) {
|
|
47
|
+
// Preserve changes made by another component after we configured the environment.
|
|
48
|
+
if (environment[key] !== previous[key]) continue;
|
|
49
|
+
if (next[key] === undefined) delete environment[key];
|
|
50
|
+
else environment[key] = next[key];
|
|
51
|
+
}
|
|
52
|
+
state.applied = next;
|
|
53
|
+
if (!state.leases.length) states.delete(environment);
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { Options, RotationRateLimit, detector, makeClient } from "./client.js";
|
|
3
|
+
import { configureProcessProxy } from "./environment.js";
|
|
4
|
+
import { discoverProxy } from "./discovery.js";
|
|
5
|
+
|
|
6
|
+
export default Plugin.define({
|
|
7
|
+
id: "tunnel-switch",
|
|
8
|
+
/** Discover the proxy, register bounded work retries and status commands, and return environment cleanup. */
|
|
9
|
+
async setup(ctx) {
|
|
10
|
+
const options = Options.parse(ctx.options);
|
|
11
|
+
const client = makeClient(options, options.apiToken);
|
|
12
|
+
const matches = detector(options);
|
|
13
|
+
const rateLimitedSessions = new Set<string>();
|
|
14
|
+
let restoreEnvironment = () => {};
|
|
15
|
+
if (options.configureProxy) {
|
|
16
|
+
let status: string;
|
|
17
|
+
try { status = await client.status(); }
|
|
18
|
+
catch (error) {
|
|
19
|
+
throw new Error(`Proxy discovery failed: could not query /status: ${String(error)}`);
|
|
20
|
+
}
|
|
21
|
+
restoreEnvironment = configureProcessProxy({ ...options, ...discoverProxy(options, status) });
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
await ctx.session.hook("retry", async (event) => {
|
|
25
|
+
// Only the first retry opportunity may rotate. OpenCode retains its hard retry limit.
|
|
26
|
+
if (event.attempt > 2) {
|
|
27
|
+
rateLimitedSessions.delete(event.sessionID);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!matches(event.model.providerID, event.error)) return;
|
|
31
|
+
// A rate-limited switch only delays the work; do not attempt another switch in this retry sequence.
|
|
32
|
+
if (event.attempt === 1) rateLimitedSessions.delete(event.sessionID);
|
|
33
|
+
else if (rateLimitedSessions.delete(event.sessionID)) return;
|
|
34
|
+
try {
|
|
35
|
+
const status = await client.rotate();
|
|
36
|
+
event.decision = { retry: true, delay: 1000 };
|
|
37
|
+
// Return the retry decision immediately so status rendering cannot hold up continuation.
|
|
38
|
+
void Promise.resolve().then(() => ctx.session.synthetic({ sessionID: event.sessionID, text: `SSH tunnel rotated: ${status}. Continuing the current work.` }))
|
|
39
|
+
.catch((error) => console.error("[tunnel-switch] Could not display rotation status", String(error)));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof RotationRateLimit) {
|
|
42
|
+
rateLimitedSessions.add(event.sessionID);
|
|
43
|
+
event.decision = { retry: true, delay: Math.ceil(error.retryAfterMs) };
|
|
44
|
+
} else {
|
|
45
|
+
event.decision = { retry: false };
|
|
46
|
+
}
|
|
47
|
+
console.error(`[tunnel-switch] ${error instanceof Error ? error.message : String(error)}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
await ctx.command.transform((editor) => {
|
|
51
|
+
editor.add({
|
|
52
|
+
name: "tunnel-status",
|
|
53
|
+
description: "Display SSH tunnel connection ID and start time",
|
|
54
|
+
execute: async ({ sessionID }) => {
|
|
55
|
+
let text: string;
|
|
56
|
+
try { text = `SSH tunnel status: ${await client.status()}`; }
|
|
57
|
+
catch (error) { text = `SSH tunnel status failed: ${String(error)}`; }
|
|
58
|
+
await ctx.session.synthetic({ sessionID, text });
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
restoreEnvironment();
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
return restoreEnvironment;
|
|
67
|
+
},
|
|
68
|
+
});
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { discoverProxy } from "./discovery.js";
|
|
3
|
+
import type { OptionsType } from "./client.js";
|
|
4
|
+
|
|
5
|
+
const Status = z.object({
|
|
6
|
+
state: z.enum(["connected", "disconnected", "rotating"]),
|
|
7
|
+
connection_id: z.string().nullable(),
|
|
8
|
+
started_at: z.iso.datetime({ offset: true }).nullable(),
|
|
9
|
+
server: z.string().nullable().optional(),
|
|
10
|
+
});
|
|
11
|
+
export type TunnelStatus = z.infer<typeof Status> & { proxy: string };
|
|
12
|
+
|
|
13
|
+
/** Validate the server response and display only allowlisted connection fields. */
|
|
14
|
+
export function parseStatus(response: string, options: OptionsType): TunnelStatus {
|
|
15
|
+
const raw: unknown = JSON.parse(response);
|
|
16
|
+
const status = Status.parse(raw);
|
|
17
|
+
let proxy = "Disabled";
|
|
18
|
+
const disabled = z.object({ http_proxy: z.object({ enabled: z.literal(false) }) }).safeParse(raw).success;
|
|
19
|
+
if (!disabled) {
|
|
20
|
+
const { proxyIp, proxyPort } = discoverProxy(options, response);
|
|
21
|
+
const host = proxyIp.includes(":") ? `[${proxyIp}]` : proxyIp;
|
|
22
|
+
proxy = `${host}:${proxyPort}`;
|
|
23
|
+
}
|
|
24
|
+
return { ...status, proxy };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Format elapsed time without reporting negative uptime for clock skew. */
|
|
28
|
+
export function uptime(startedAt: string | null, now: number): string {
|
|
29
|
+
if (!startedAt) return "—";
|
|
30
|
+
const seconds = Math.max(0, Math.floor((now - Date.parse(startedAt)) / 1000));
|
|
31
|
+
return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds / 60) % 60}m ${seconds % 60}s`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Poll serially, retain the last good response on failure, and stop on disposal. */
|
|
35
|
+
export function watchStatus(read: () => Promise<string>, options: OptionsType,
|
|
36
|
+
update: (status: TunnelStatus | undefined, available: boolean, checked: number, lastSuccessful: number | undefined) => void,
|
|
37
|
+
intervalMs = 5000) {
|
|
38
|
+
let stopped = false;
|
|
39
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
40
|
+
let pending: Promise<void> | undefined;
|
|
41
|
+
let status: TunnelStatus | undefined;
|
|
42
|
+
let lastSuccessful: number | undefined;
|
|
43
|
+
function refresh(): Promise<void> {
|
|
44
|
+
if (stopped) return Promise.resolve();
|
|
45
|
+
if (pending) return pending;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
pending = (async () => {
|
|
48
|
+
let available = false;
|
|
49
|
+
try {
|
|
50
|
+
status = parseStatus(await read(), options);
|
|
51
|
+
lastSuccessful = Date.now();
|
|
52
|
+
available = true;
|
|
53
|
+
} catch { /* Never display API errors or credentials. */ }
|
|
54
|
+
if (!stopped) update(status, available, Date.now(), lastSuccessful);
|
|
55
|
+
})().finally(() => {
|
|
56
|
+
pending = undefined;
|
|
57
|
+
if (!stopped) timer = setTimeout(() => { void refresh(); }, intervalMs);
|
|
58
|
+
});
|
|
59
|
+
return pending;
|
|
60
|
+
}
|
|
61
|
+
void refresh();
|
|
62
|
+
return { refresh, stop() { stopped = true; clearTimeout(timer); } };
|
|
63
|
+
}
|
package/src/tui.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
import { Options, makeClient, type OptionsType } from "./client.js";
|
|
3
|
+
import { uptime, watchStatus, type TunnelStatus } from "./status.js";
|
|
4
|
+
|
|
5
|
+
type JsxRuntime = typeof import("@opentui/solid/jsx-runtime");
|
|
6
|
+
type SolidRuntime = typeof import("solid-js");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The TUI runtime shares its OpenTUI/Solid singletons with plugins through
|
|
10
|
+
* virtual `opentui:runtime-module:` ids (registered by OpenCode at startup).
|
|
11
|
+
* Importing `@opentui/solid`/`solid-js` directly loads a second renderer
|
|
12
|
+
* instance whose `RendererContext` is never provided, so every slot render
|
|
13
|
+
* throws "No renderer found". Prefer the host ids, then the local packages
|
|
14
|
+
* (test harnesses), then give up.
|
|
15
|
+
*/
|
|
16
|
+
const runtimeModule = (specifier: string) => `opentui:runtime-module:${encodeURIComponent(specifier)}`;
|
|
17
|
+
|
|
18
|
+
async function uiRuntime(): Promise<{ readonly jsx: JsxRuntime["jsx"]; readonly createSignal: SolidRuntime["createSignal"] } | undefined> {
|
|
19
|
+
try {
|
|
20
|
+
const [jsxRuntime, solid] = await Promise.all([
|
|
21
|
+
import(runtimeModule("@opentui/solid/jsx-runtime")) as Promise<JsxRuntime>,
|
|
22
|
+
import(runtimeModule("solid-js")) as Promise<SolidRuntime>,
|
|
23
|
+
]);
|
|
24
|
+
return { jsx: jsxRuntime.jsx, createSignal: solid.createSignal };
|
|
25
|
+
} catch {
|
|
26
|
+
try {
|
|
27
|
+
const [jsxRuntime, solid] = await Promise.all([
|
|
28
|
+
import("@opentui/solid/jsx-runtime"),
|
|
29
|
+
import("solid-js"),
|
|
30
|
+
]);
|
|
31
|
+
return { jsx: jsxRuntime.jsx, createSignal: solid.createSignal };
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Format an ISO timestamp as HH:MM:SS DD/MM/YY. */
|
|
39
|
+
function formatTimestamp(value: string | null | undefined): string {
|
|
40
|
+
if (!value) return "—";
|
|
41
|
+
const date = new Date(value);
|
|
42
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
43
|
+
const pad = (part: number) => String(part).padStart(2, "0");
|
|
44
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())} `
|
|
45
|
+
+ `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${pad(date.getFullYear() % 100)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Compact relative age for the last check, e.g. "25s ago" or "2.5hr ago". */
|
|
49
|
+
function formatAge(from: number, to: number): string {
|
|
50
|
+
const seconds = Math.max(0, Math.round((to - from) / 1000));
|
|
51
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
52
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
|
|
53
|
+
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}hr ago`;
|
|
54
|
+
return `${(seconds / 86400).toFixed(1)}d ago`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default Plugin.define({
|
|
58
|
+
id: "tunnel-switch",
|
|
59
|
+
/** Add a read-only sidebar with bounded serial polling and disposal cleanup. */
|
|
60
|
+
async setup(ctx) {
|
|
61
|
+
let options: OptionsType;
|
|
62
|
+
try {
|
|
63
|
+
options = Options.parse(ctx.options);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// OpenCode's TUI runtime only receives options declared in cli.json; options
|
|
66
|
+
// from opencode.json(c) stay server-side, so degrade with actionable guidance.
|
|
67
|
+
const issue = (error as { issues?: Array<{ path: PropertyKey[]; message: string }> }).issues?.[0];
|
|
68
|
+
const detail = issue ? `${issue.path.join(".")}: ${issue.message}` : String(error);
|
|
69
|
+
ctx.ui.toast.show({
|
|
70
|
+
variant: "error",
|
|
71
|
+
title: "tunnel-switch: options unavailable",
|
|
72
|
+
message: `${detail} — declare this plugin with its options in cli.json to enable the `
|
|
73
|
+
+ "tunnel sidebar.",
|
|
74
|
+
duration: 10000,
|
|
75
|
+
});
|
|
76
|
+
return () => {};
|
|
77
|
+
}
|
|
78
|
+
const ui = await uiRuntime();
|
|
79
|
+
if (!ui) {
|
|
80
|
+
ctx.ui.toast.show({
|
|
81
|
+
variant: "error",
|
|
82
|
+
title: "tunnel-switch: sidebar unavailable",
|
|
83
|
+
message: "Neither the host UI runtime nor a local @opentui/solid install is available; "
|
|
84
|
+
+ "the tunnel still rotates through the server plugin.",
|
|
85
|
+
duration: 10000,
|
|
86
|
+
});
|
|
87
|
+
return () => {};
|
|
88
|
+
}
|
|
89
|
+
const { jsx, createSignal } = ui;
|
|
90
|
+
// Follow the MCP sidebar's conventions: <text fg> for whole-line colours,
|
|
91
|
+
// style={{ fg }} on <span> for inline runs (a bare fg prop on a span is
|
|
92
|
+
// silently dropped by the reconciler), theme feedback colours for states.
|
|
93
|
+
const themed = (path: readonly string[], fallback: string): string => {
|
|
94
|
+
let current: unknown = ctx.theme;
|
|
95
|
+
for (const key of path) current = (current as Record<string, unknown> | undefined)?.[key];
|
|
96
|
+
return typeof current === "string" ? current : fallback;
|
|
97
|
+
};
|
|
98
|
+
const base = themed(["text", "base"], "#ffffff");
|
|
99
|
+
const muted = themed(["text", "muted"], "#868895");
|
|
100
|
+
const positive = themed(["text", "feedback", "success", "base"], "#a6e3a1");
|
|
101
|
+
const warning = themed(["text", "feedback", "warning", "base"], "#f9e2af");
|
|
102
|
+
const negative = themed(["text", "feedback", "error", "base"], "#f38ba8");
|
|
103
|
+
// A row: bold title in the theme's base colour, two-space indented and
|
|
104
|
+
// without a colon; value flush right (muted or state-coloured), matching
|
|
105
|
+
// the sibling sidebars' label/value layout.
|
|
106
|
+
const line = (label: string, value: string | (() => string), colour?: string, suffix?: string) =>
|
|
107
|
+
jsx("box", { flexDirection: "row", gap: 1, minWidth: 0, get children() { return [
|
|
108
|
+
jsx("text", { fg: base, wrapMode: "none", truncate: true, flexGrow: 1, flexShrink: 1, minWidth: 0,
|
|
109
|
+
get children() { return jsx("b", { children: ` ${label}` }); } }),
|
|
110
|
+
jsx("text", { wrapMode: "none", truncate: true, flexShrink: 0, get children() {
|
|
111
|
+
const children = [jsx("span", {
|
|
112
|
+
style: { fg: colour ?? muted },
|
|
113
|
+
get children() { return typeof value === "function" ? value() : value; },
|
|
114
|
+
})];
|
|
115
|
+
if (suffix) children.push(jsx("span", { style: { fg: muted }, children: suffix }));
|
|
116
|
+
return children;
|
|
117
|
+
} }),
|
|
118
|
+
]; } });
|
|
119
|
+
// Muted summary shown beside "Tunnel" while the sidebar is collapsed,
|
|
120
|
+
// naming the tunnel (its configured server) once one has been reported.
|
|
121
|
+
// The name is capped so a long hostname cannot push the reachability
|
|
122
|
+
// state out of the collapsed header.
|
|
123
|
+
/** Keep a server name within 20 columns, elided with an ellipsis. */
|
|
124
|
+
const clipName = (name: string): string => (name.length <= 20 ? name : `${name.slice(0, 19)}…`);
|
|
125
|
+
const collapsedSummary = (name: string | null | undefined, checked: number | undefined,
|
|
126
|
+
available: boolean): string => {
|
|
127
|
+
let reach: string;
|
|
128
|
+
if (!checked) reach = "checking…";
|
|
129
|
+
else if (available) reach = "reachable";
|
|
130
|
+
else reach = "unavailable";
|
|
131
|
+
if (!name) return ` (${reach})`;
|
|
132
|
+
return ` (${clipName(name)} · ${reach})`;
|
|
133
|
+
};
|
|
134
|
+
// Feedback colour for a tunnel state.
|
|
135
|
+
const stateColour = (state: TunnelStatus["state"]): string => {
|
|
136
|
+
if (state === "connected") return positive;
|
|
137
|
+
if (state === "rotating") return warning;
|
|
138
|
+
return negative;
|
|
139
|
+
};
|
|
140
|
+
const client = makeClient(options, options.apiToken);
|
|
141
|
+
const [snapshot, setSnapshot] = createSignal<{
|
|
142
|
+
status?: TunnelStatus; available: boolean; checked?: number; lastSuccessful?: number;
|
|
143
|
+
}>({ available: false });
|
|
144
|
+
const [clock, setClock] = createSignal(Date.now());
|
|
145
|
+
const [expanded, setExpanded] = createSignal(true);
|
|
146
|
+
// Tick once a second so the relative "Checked" age stays live between polls.
|
|
147
|
+
const ticker = setInterval(() => setClock(Date.now()), 1000);
|
|
148
|
+
const watcher = watchStatus(client.status, options, (status, available, checked, lastSuccessful) => {
|
|
149
|
+
setSnapshot({ status, available, checked, lastSuccessful });
|
|
150
|
+
});
|
|
151
|
+
let removeSlot: (() => void) | undefined;
|
|
152
|
+
let warned = false;
|
|
153
|
+
const stop = () => { clearInterval(ticker); watcher.stop(); removeSlot?.(); };
|
|
154
|
+
try {
|
|
155
|
+
removeSlot = ctx.ui.slot({
|
|
156
|
+
append: "sidebar.content",
|
|
157
|
+
render: () => {
|
|
158
|
+
try {
|
|
159
|
+
return jsx("box", { flexDirection: "column", marginTop: 1, get children() {
|
|
160
|
+
const { status, available, checked, lastSuccessful } = snapshot();
|
|
161
|
+
const open = expanded();
|
|
162
|
+
// Collapsible header, toggled by clicking like the MCP sidebar box.
|
|
163
|
+
const header = jsx("box", { flexDirection: "row", gap: 1, onMouseDown: () => setExpanded(!open), get children() { return [
|
|
164
|
+
jsx("text", { fg: base, children: open ? "▼" : "▶" }),
|
|
165
|
+
jsx("text", { fg: base, get children() {
|
|
166
|
+
const children = [jsx("b", { children: "Tunnel" })];
|
|
167
|
+
if (!open) children.push(jsx("span", { style: { fg: muted },
|
|
168
|
+
children: collapsedSummary(status?.server, checked, available) }));
|
|
169
|
+
return children;
|
|
170
|
+
} }),
|
|
171
|
+
]; } });
|
|
172
|
+
if (!open) return [header];
|
|
173
|
+
if (!checked) return [header, jsx("text", { fg: muted, children: "Checking status…" })];
|
|
174
|
+
const lines = [
|
|
175
|
+
line("API", available ? "reachable" : "unavailable", available ? positive : negative),
|
|
176
|
+
];
|
|
177
|
+
if (status) {
|
|
178
|
+
lines.push(
|
|
179
|
+
line("State", status.state, stateColour(status.state), available ? undefined : " (last known)"),
|
|
180
|
+
line("Server", status.server ?? "—"),
|
|
181
|
+
line("Started", formatTimestamp(status.started_at)),
|
|
182
|
+
line("Uptime", () => uptime(status.started_at, lastSuccessful ?? checked), undefined,
|
|
183
|
+
available ? undefined : " (at last success)"),
|
|
184
|
+
line("Proxy", status.proxy),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
// The relative age stays muted (the default value colour) between polls.
|
|
188
|
+
lines.push(line("Checked", () => formatAge(checked, clock()), muted));
|
|
189
|
+
return [header, ...lines];
|
|
190
|
+
} });
|
|
191
|
+
} catch {
|
|
192
|
+
// The local fallback instance cannot render against the host's
|
|
193
|
+
// renderer; degrade once instead of crashing the slot boundary.
|
|
194
|
+
if (!warned) {
|
|
195
|
+
warned = true;
|
|
196
|
+
ctx.ui.toast.show({
|
|
197
|
+
variant: "error",
|
|
198
|
+
title: "tunnel-switch: sidebar unavailable",
|
|
199
|
+
message: "This OpenCode build did not share its UI runtime with the plugin "
|
|
200
|
+
+ "(OpenTUI instance mismatch); the tunnel still rotates server-side.",
|
|
201
|
+
duration: 10000,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
ctx.keymap.layer(() => ({
|
|
209
|
+
mode: "global",
|
|
210
|
+
commands: [{
|
|
211
|
+
id: "tunnel-switch.refresh", title: "Refresh tunnel status", group: "Tunnel",
|
|
212
|
+
palette: true, slash: { name: "tunnel-refresh" },
|
|
213
|
+
run: async () => { await watcher.refresh(); },
|
|
214
|
+
}, {
|
|
215
|
+
id: "tunnel-switch.toggle", title: "Toggle tunnel sidebar details", group: "Tunnel",
|
|
216
|
+
palette: true, slash: { name: "tunnel-toggle" },
|
|
217
|
+
run: () => { setExpanded(!expanded()); },
|
|
218
|
+
}],
|
|
219
|
+
}));
|
|
220
|
+
} catch (error) {
|
|
221
|
+
stop();
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
return stop;
|
|
225
|
+
},
|
|
226
|
+
});
|
package/tui.ts
ADDED