@elizaos/plugin-tunnel 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +39 -0
  2. package/dist/__tests__/TunnelTestSuite.d.ts +6 -0
  3. package/dist/__tests__/TunnelTestSuite.d.ts.map +1 -0
  4. package/dist/__tests__/TunnelTestSuite.js +47 -0
  5. package/dist/__tests__/TunnelTestSuite.js.map +1 -0
  6. package/dist/actions/get-tunnel-status.d.ts +3 -0
  7. package/dist/actions/get-tunnel-status.d.ts.map +1 -0
  8. package/dist/actions/get-tunnel-status.js +43 -0
  9. package/dist/actions/get-tunnel-status.js.map +1 -0
  10. package/dist/actions/start-tunnel.d.ts +3 -0
  11. package/dist/actions/start-tunnel.d.ts.map +1 -0
  12. package/dist/actions/start-tunnel.js +96 -0
  13. package/dist/actions/start-tunnel.js.map +1 -0
  14. package/dist/actions/stop-tunnel.d.ts +3 -0
  15. package/dist/actions/stop-tunnel.d.ts.map +1 -0
  16. package/dist/actions/stop-tunnel.js +41 -0
  17. package/dist/actions/stop-tunnel.js.map +1 -0
  18. package/dist/actions/tunnel.d.ts +20 -0
  19. package/dist/actions/tunnel.d.ts.map +1 -0
  20. package/dist/actions/tunnel.js +159 -0
  21. package/dist/actions/tunnel.js.map +1 -0
  22. package/dist/environment.d.ts +18 -0
  23. package/dist/environment.d.ts.map +1 -0
  24. package/dist/environment.js +66 -0
  25. package/dist/environment.js.map +1 -0
  26. package/dist/index.d.ts +21 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +46 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/providers/tunnel-state.d.ts +12 -0
  31. package/dist/providers/tunnel-state.d.ts.map +1 -0
  32. package/dist/providers/tunnel-state.js +43 -0
  33. package/dist/providers/tunnel-state.js.map +1 -0
  34. package/dist/services/LocalTunnelService.d.ts +37 -0
  35. package/dist/services/LocalTunnelService.d.ts.map +1 -0
  36. package/dist/services/LocalTunnelService.js +186 -0
  37. package/dist/services/LocalTunnelService.js.map +1 -0
  38. package/dist/types.d.ts +47 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +34 -0
  41. package/dist/types.js.map +1 -0
  42. package/package.json +59 -0
  43. package/src/__tests__/TunnelTestSuite.ts +46 -0
  44. package/src/__tests__/unit/local-tunnel-service.test.ts +23 -0
  45. package/src/actions/get-tunnel-status.ts +60 -0
  46. package/src/actions/start-tunnel.ts +117 -0
  47. package/src/actions/stop-tunnel.ts +58 -0
  48. package/src/actions/tunnel.ts +193 -0
  49. package/src/environment.ts +74 -0
  50. package/src/index.ts +59 -0
  51. package/src/providers/tunnel-state.ts +46 -0
  52. package/src/services/LocalTunnelService.ts +228 -0
  53. package/src/types.ts +62 -0
@@ -0,0 +1,117 @@
1
+ import {
2
+ type ActionResult,
3
+ elizaLogger,
4
+ type HandlerCallback,
5
+ type IAgentRuntime,
6
+ type Memory,
7
+ ModelType,
8
+ type State,
9
+ } from '@elizaos/core';
10
+ import { z } from 'zod';
11
+ import { getTunnelService } from '../types';
12
+
13
+ const portPayloadSchema = z.object({
14
+ port: z.union([z.number(), z.string().regex(/^\d+$/)]).transform((value) => {
15
+ const num = typeof value === 'string' ? Number.parseInt(value, 10) : value;
16
+ return num;
17
+ }),
18
+ });
19
+
20
+ const PORT_PROMPT_TEMPLATE = `Respond with a JSON object containing the port number to start the tunnel on.
21
+ The user said: "{{userMessage}}"
22
+
23
+ Extract the port number from their message, or use the default port 3000 if not specified.
24
+
25
+ Response format:
26
+ \`\`\`json
27
+ { "port": 3000 }
28
+ \`\`\``;
29
+
30
+ const DEFAULT_PORT = 3000;
31
+
32
+ function isValidPort(value: number): boolean {
33
+ return Number.isInteger(value) && value >= 1 && value <= 65535;
34
+ }
35
+
36
+ function parsePort(value: string): number {
37
+ try {
38
+ const parsed: unknown = JSON.parse(value);
39
+ const result = portPayloadSchema.safeParse(parsed);
40
+ if (result.success && isValidPort(result.data.port)) return result.data.port;
41
+ } catch {
42
+ // fall through
43
+ }
44
+ const match = value.match(/\b(\d{1,5})\b/);
45
+ const captured = match?.[1];
46
+ if (!captured) return DEFAULT_PORT;
47
+ const num = Number.parseInt(captured, 10);
48
+ return isValidPort(num) ? num : DEFAULT_PORT;
49
+ }
50
+
51
+ export async function handleStartTunnel(
52
+ runtime: IAgentRuntime,
53
+ message: Memory,
54
+ _state?: State,
55
+ options?: Record<string, unknown>,
56
+ callback?: HandlerCallback
57
+ ): Promise<ActionResult> {
58
+ const tunnelService = getTunnelService(runtime);
59
+ if (!tunnelService) {
60
+ if (callback) {
61
+ await callback({
62
+ text: 'Tunnel service is not available. Configure plugin-tunnel, plugin-elizacloud, or plugin-ngrok.',
63
+ });
64
+ }
65
+ return { success: false, error: 'tunnel service unavailable' };
66
+ }
67
+
68
+ if (tunnelService.isActive()) {
69
+ if (callback) {
70
+ await callback({
71
+ text: 'Tunnel is already active. Stop the existing tunnel before starting a new one.',
72
+ });
73
+ }
74
+ return { success: false, error: 'tunnel already active' };
75
+ }
76
+
77
+ elizaLogger.info('[start-tunnel] starting tunnel');
78
+
79
+ let port: number | undefined;
80
+ const explicitPort = options?.port;
81
+ if (typeof explicitPort === 'number' && isValidPort(explicitPort)) {
82
+ port = explicitPort;
83
+ } else if (typeof explicitPort === 'string' && /^\d+$/.test(explicitPort)) {
84
+ const parsed = Number.parseInt(explicitPort, 10);
85
+ if (isValidPort(parsed)) port = parsed;
86
+ }
87
+
88
+ if (port === undefined) {
89
+ const userMessage = message.content.text ?? '';
90
+ const portResponse = await runtime.useModel(ModelType.TEXT_SMALL, {
91
+ prompt: PORT_PROMPT_TEMPLATE.replace('{{userMessage}}', userMessage),
92
+ temperature: 0.3,
93
+ });
94
+ port = parsePort(String(portResponse));
95
+ }
96
+
97
+ const url = await tunnelService.startTunnel(port);
98
+ const publicUrl = typeof url === 'string' ? url : tunnelService.getUrl();
99
+ const status = tunnelService.getStatus();
100
+
101
+ if (callback) {
102
+ await callback({
103
+ text: `Tunnel started (${status.provider}).\n\nURL: ${publicUrl ?? 'unknown'}\nLocal port: ${port}`,
104
+ });
105
+ }
106
+
107
+ return {
108
+ success: true,
109
+ text: `Tunnel started on port ${port}`,
110
+ data: {
111
+ action: 'tunnel_started',
112
+ tunnelUrl: publicUrl ?? '',
113
+ port,
114
+ provider: status.provider,
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,58 @@
1
+ import {
2
+ type ActionResult,
3
+ elizaLogger,
4
+ type HandlerCallback,
5
+ type IAgentRuntime,
6
+ type Memory,
7
+ type State,
8
+ } from '@elizaos/core';
9
+ import { getTunnelService } from '../types';
10
+
11
+ export async function handleStopTunnel(
12
+ runtime: IAgentRuntime,
13
+ _message?: Memory,
14
+ _state?: State,
15
+ _options?: Record<string, unknown>,
16
+ callback?: HandlerCallback
17
+ ): Promise<ActionResult> {
18
+ const tunnelService = getTunnelService(runtime);
19
+ if (!tunnelService) {
20
+ if (callback) {
21
+ await callback({ text: 'Tunnel service is not available.' });
22
+ }
23
+ return { success: false, error: 'tunnel service unavailable' };
24
+ }
25
+
26
+ if (!tunnelService.isActive()) {
27
+ elizaLogger.warn('[stop-tunnel] no active tunnel to stop');
28
+ if (callback) {
29
+ await callback({ text: 'No tunnel is currently running.' });
30
+ }
31
+ return {
32
+ success: true,
33
+ text: 'no active tunnel',
34
+ data: { action: 'tunnel_not_active' },
35
+ };
36
+ }
37
+
38
+ const status = tunnelService.getStatus();
39
+ const previousUrl = status.url;
40
+ const previousPort = status.port;
41
+
42
+ await tunnelService.stopTunnel();
43
+
44
+ if (callback) {
45
+ await callback({
46
+ text: `Tunnel stopped.\n\nWas running on port: ${previousPort}\nPrevious URL: ${previousUrl}`,
47
+ });
48
+ }
49
+ return {
50
+ success: true,
51
+ text: `Tunnel stopped (was on port ${previousPort})`,
52
+ data: {
53
+ action: 'tunnel_stopped',
54
+ previousUrl: previousUrl ?? '',
55
+ previousPort: previousPort ?? 0,
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @module tunnel
3
+ * @description Single dispatcher action that fans out to the active
4
+ * tunnel-service implementation. The action's name is `TUNNEL`; legacy
5
+ * `TAILSCALE`-prefixed action names are kept as `similes` so older
6
+ * characters and callers still resolve.
7
+ *
8
+ * Sub-ops (selected via the `action` parameter-enum):
9
+ * - start -> handleStartTunnel (optional `port`, defaults to 3000)
10
+ * - stop -> handleStopTunnel (no parameters)
11
+ * - status -> handleGetTunnelStatus (no parameters)
12
+ *
13
+ * The handler accepts both call shapes:
14
+ * 1. `{ action, ...subParams }`
15
+ * 2. `{ parameters: { action, parameters: { ...subParams } } }` (LLM extraction)
16
+ */
17
+
18
+ import type {
19
+ Action,
20
+ ActionResult,
21
+ HandlerCallback,
22
+ IAgentRuntime,
23
+ Memory,
24
+ State,
25
+ } from '@elizaos/core';
26
+ import { getTunnelService } from '../types';
27
+ import { handleGetTunnelStatus } from './get-tunnel-status';
28
+ import { handleStartTunnel } from './start-tunnel';
29
+ import { handleStopTunnel } from './stop-tunnel';
30
+
31
+ const SUPPORTED_OPS = ['start', 'stop', 'status'] as const;
32
+
33
+ function pickRecord(value: unknown): Record<string, unknown> | undefined {
34
+ return value && typeof value === 'object' && !Array.isArray(value)
35
+ ? (value as Record<string, unknown>)
36
+ : undefined;
37
+ }
38
+
39
+ function resolveDispatch(options: Record<string, unknown> | undefined): {
40
+ action: string | null;
41
+ subOptions: Record<string, unknown>;
42
+ } {
43
+ if (!options) {
44
+ return { action: null, subOptions: {} };
45
+ }
46
+ const nested = pickRecord(options.parameters);
47
+ const actionSource = nested ?? options;
48
+ const rawAction = actionSource.action ?? actionSource.subaction ?? actionSource.op;
49
+ const action = typeof rawAction === 'string' ? rawAction.toLowerCase() : null;
50
+
51
+ let subOptions: Record<string, unknown>;
52
+ if (nested) {
53
+ const innerParams = pickRecord(nested.parameters);
54
+ if (innerParams) {
55
+ subOptions = { ...innerParams };
56
+ } else {
57
+ const {
58
+ action: _omitAction,
59
+ subaction: _omitSubaction,
60
+ op: _omitOp,
61
+ parameters: _omitParams,
62
+ ...rest
63
+ } = nested;
64
+ subOptions = rest;
65
+ }
66
+ } else {
67
+ const { action: _omitAction, subaction: _omitSubaction, op: _omitOp, ...rest } = options;
68
+ subOptions = rest;
69
+ }
70
+
71
+ return { action, subOptions };
72
+ }
73
+
74
+ export const tunnelAction: Action = {
75
+ name: 'TUNNEL',
76
+ similes: [
77
+ // Legacy action names kept so existing characters/transcripts still resolve.
78
+ 'TAILSCALE',
79
+ 'START_TAILSCALE',
80
+ 'STOP_TAILSCALE',
81
+ 'GET_TAILSCALE_STATUS',
82
+ 'START_TUNNEL',
83
+ 'OPEN_TUNNEL',
84
+ 'CREATE_TUNNEL',
85
+ 'TAILSCALE_UP',
86
+ 'STOP_TUNNEL',
87
+ 'CLOSE_TUNNEL',
88
+ 'TAILSCALE_DOWN',
89
+ 'TAILSCALE_STATUS',
90
+ 'CHECK_TUNNEL',
91
+ 'TUNNEL_INFO',
92
+ 'TUNNEL_STATUS',
93
+ ],
94
+ description:
95
+ 'Tunnel operations dispatched by `action`: start, stop, status. The `start` action accepts an optional `port` (defaults to 3000); `stop` and `status` take no parameters. Backed by whichever tunnel plugin is active (local Tailscale CLI, Eliza Cloud headscale, or ngrok).',
96
+
97
+ parameters: [
98
+ {
99
+ name: 'action',
100
+ description: 'Which tunnel sub-operation to run. One of: start, stop, status.',
101
+ required: true,
102
+ schema: {
103
+ type: 'string',
104
+ enum: [...SUPPORTED_OPS],
105
+ },
106
+ },
107
+ {
108
+ name: 'parameters',
109
+ description:
110
+ 'Parameters forwarded to the selected sub-op. For `start`, optionally `{ port: number }`. `stop` and `status` take no parameters.',
111
+ required: false,
112
+ schema: { type: 'object' },
113
+ },
114
+ ],
115
+
116
+ validate: async (runtime: IAgentRuntime): Promise<boolean> => {
117
+ return Boolean(getTunnelService(runtime));
118
+ },
119
+
120
+ handler: async (
121
+ runtime: IAgentRuntime,
122
+ message: Memory,
123
+ state?: State,
124
+ options?: Record<string, unknown>,
125
+ callback?: HandlerCallback
126
+ ): Promise<ActionResult> => {
127
+ const { action, subOptions } = resolveDispatch(options);
128
+
129
+ if (!action) {
130
+ const err = `TUNNEL requires action=start|stop|status`;
131
+ if (callback) await callback({ text: err });
132
+ return { success: false, error: err };
133
+ }
134
+
135
+ switch (action) {
136
+ case 'start':
137
+ return handleStartTunnel(runtime, message, state, subOptions, callback);
138
+ case 'stop':
139
+ return handleStopTunnel(runtime, message, state, subOptions, callback);
140
+ case 'status':
141
+ return handleGetTunnelStatus(runtime, message, state, subOptions, callback);
142
+ default: {
143
+ const err = `Unknown TUNNEL action "${action}". Supported: ${SUPPORTED_OPS.join(', ')}`;
144
+ if (callback) await callback({ text: err });
145
+ return { success: false, error: err };
146
+ }
147
+ }
148
+ },
149
+
150
+ examples: [
151
+ [
152
+ {
153
+ name: '{{user1}}',
154
+ content: { text: 'Start a tunnel on port 8080' },
155
+ },
156
+ {
157
+ name: '{{agentName}}',
158
+ content: {
159
+ text: 'Tunnel started (tailscale).\n\nURL: https://device.tail-scale.ts.net\nLocal port: 8080',
160
+ actions: ['TUNNEL'],
161
+ },
162
+ },
163
+ ],
164
+ [
165
+ {
166
+ name: '{{user1}}',
167
+ content: { text: 'Stop the tunnel' },
168
+ },
169
+ {
170
+ name: '{{agentName}}',
171
+ content: {
172
+ text: 'Tunnel stopped.',
173
+ actions: ['TUNNEL'],
174
+ },
175
+ },
176
+ ],
177
+ [
178
+ {
179
+ name: '{{user1}}',
180
+ content: { text: 'What is the tunnel status?' },
181
+ },
182
+ {
183
+ name: '{{agentName}}',
184
+ content: {
185
+ text: '✅ tunnel active (tailscale).',
186
+ actions: ['TUNNEL'],
187
+ },
188
+ },
189
+ ],
190
+ ],
191
+ };
192
+
193
+ export default tunnelAction;
@@ -0,0 +1,74 @@
1
+ import type { IAgentRuntime } from '@elizaos/core';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Local-CLI tunnel config. The cloud-backed (headscale) flavor lives in
6
+ * `@elizaos/plugin-elizacloud`; this package only configures the local
7
+ * `tailscale` CLI.
8
+ *
9
+ * `TUNNEL_*` env vars are the canonical names. `TAILSCALE_*` are accepted
10
+ * as aliases for one release window so existing `.env` files keep working.
11
+ */
12
+ export const tunnelEnvSchema = z.object({
13
+ TUNNEL_TAGS: z
14
+ .union([z.string(), z.array(z.string())])
15
+ .optional()
16
+ .transform((value) => {
17
+ if (Array.isArray(value)) return value.filter((tag) => tag.length > 0);
18
+ if (typeof value === 'string' && value.length > 0)
19
+ return value
20
+ .split(',')
21
+ .map((tag) => tag.trim())
22
+ .filter((tag) => tag.length > 0);
23
+ return ['tag:eliza-tunnel'];
24
+ })
25
+ .default(['tag:eliza-tunnel']),
26
+ TUNNEL_FUNNEL: z
27
+ .union([z.string(), z.boolean()])
28
+ .optional()
29
+ .transform((value) => value === true || value === 'true' || value === '1')
30
+ .default(false),
31
+ TUNNEL_DEFAULT_PORT: z
32
+ .union([z.string(), z.number()])
33
+ .optional()
34
+ .transform((value) => {
35
+ if (value === undefined || value === '') return 3000;
36
+ const num = typeof value === 'string' ? Number.parseInt(value, 10) : value;
37
+ if (Number.isNaN(num) || num <= 0 || num > 65535) return 3000;
38
+ return num;
39
+ })
40
+ .default(3000),
41
+ });
42
+
43
+ export type TunnelConfig = z.infer<typeof tunnelEnvSchema>;
44
+
45
+ function readSetting(runtime: IAgentRuntime, key: string): string | undefined {
46
+ const value = runtime.getSetting(key);
47
+ if (value === null || value === undefined) return undefined;
48
+ return String(value);
49
+ }
50
+
51
+ /**
52
+ * Read `TUNNEL_<KEY>` first, then fall back to the legacy `TAILSCALE_<KEY>`.
53
+ */
54
+ function readWithLegacy(
55
+ runtime: IAgentRuntime,
56
+ newKey: string,
57
+ legacyKey: string
58
+ ): string | undefined {
59
+ return (
60
+ readSetting(runtime, newKey) ??
61
+ readSetting(runtime, legacyKey) ??
62
+ process.env[newKey] ??
63
+ process.env[legacyKey]
64
+ );
65
+ }
66
+
67
+ export async function validateTunnelConfig(runtime: IAgentRuntime): Promise<TunnelConfig> {
68
+ const config = {
69
+ TUNNEL_TAGS: readWithLegacy(runtime, 'TUNNEL_TAGS', 'TAILSCALE_TAGS'),
70
+ TUNNEL_FUNNEL: readWithLegacy(runtime, 'TUNNEL_FUNNEL', 'TAILSCALE_FUNNEL'),
71
+ TUNNEL_DEFAULT_PORT: readWithLegacy(runtime, 'TUNNEL_DEFAULT_PORT', 'TAILSCALE_DEFAULT_PORT'),
72
+ };
73
+ return tunnelEnvSchema.parse(config);
74
+ }
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { elizaLogger, promoteSubactionsToActions, type Plugin } from '@elizaos/core';
2
+ import { TunnelTestSuite } from './__tests__/TunnelTestSuite';
3
+ import { tunnelAction } from './actions/tunnel';
4
+ import { tunnelStateProvider } from './providers/tunnel-state';
5
+ import { checkTailscaleInstalled, LocalTunnelService } from './services/LocalTunnelService';
6
+ import { tunnelSlotIsFree } from './types';
7
+
8
+ /**
9
+ * Local Tailscale-CLI tunnel plugin.
10
+ *
11
+ * Registers `serviceType = "tunnel"` ONLY when:
12
+ * 1. No other tunnel service has already claimed the slot (first-active-wins
13
+ * across plugin-tunnel, plugin-elizacloud, plugin-ngrok), AND
14
+ * 2. The `tailscale` CLI is on PATH.
15
+ *
16
+ * The Eliza Cloud (headscale) tunnel lives in `@elizaos/plugin-elizacloud`.
17
+ * Ngrok lives in `@elizaos/plugin-ngrok`. Enable as many as you like — only
18
+ * one will bind.
19
+ */
20
+ export const tunnelPlugin: Plugin = {
21
+ name: 'tunnel',
22
+ description:
23
+ 'Local Tailscale-CLI tunnel backend (serve / funnel). Coexists with plugin-elizacloud and plugin-ngrok via first-active-wins registration.',
24
+ actions: [...promoteSubactionsToActions(tunnelAction)],
25
+ providers: [tunnelStateProvider],
26
+ tests: [new TunnelTestSuite()],
27
+ init: async (_config, runtime) => {
28
+ if (!tunnelSlotIsFree(runtime)) {
29
+ elizaLogger.info(
30
+ '[plugin-tunnel] another tunnel service already registered — skipping LocalTunnelService'
31
+ );
32
+ return;
33
+ }
34
+ const installed = await checkTailscaleInstalled();
35
+ if (!installed) {
36
+ elizaLogger.info(
37
+ '[plugin-tunnel] tailscale CLI not found on PATH — skipping LocalTunnelService'
38
+ );
39
+ return;
40
+ }
41
+ elizaLogger.info('[plugin-tunnel] registering LocalTunnelService for serviceType="tunnel"');
42
+ await runtime.registerService(LocalTunnelService);
43
+ },
44
+ };
45
+
46
+ export default tunnelPlugin;
47
+
48
+ export { tunnelAction } from './actions/tunnel';
49
+ export { type TunnelConfig, validateTunnelConfig } from './environment';
50
+ export { tunnelStateProvider } from './providers/tunnel-state';
51
+ // Public surface for consumers and sibling tunnel plugins.
52
+ export { checkTailscaleInstalled, LocalTunnelService } from './services/LocalTunnelService';
53
+ export {
54
+ getTunnelService,
55
+ type ITunnelService,
56
+ type TunnelProvider,
57
+ type TunnelStatus,
58
+ tunnelSlotIsFree,
59
+ } from './types';
@@ -0,0 +1,46 @@
1
+ import type { Provider } from '@elizaos/core';
2
+ import { getTunnelService } from '../types';
3
+
4
+ /**
5
+ * Surfaces the active tunnel's status into the model's state. Renders a
6
+ * one-line text summary and exposes the raw status object via `data`.
7
+ *
8
+ * Backend-agnostic — works with whichever tunnel implementation
9
+ * (`LocalTunnelService` here, `CloudTunnelService` in plugin-elizacloud,
10
+ * the ngrok service, etc.) won the `serviceType="tunnel"` slot.
11
+ */
12
+ export const tunnelStateProvider: Provider = {
13
+ name: 'TUNNEL_STATE',
14
+ description: 'Current tunnel status: active flag, public URL, local port, provider, backend.',
15
+ descriptionCompressed: 'Tunnel: active/url/port/provider/backend',
16
+ contexts: ['devtools', 'system'],
17
+ relevanceKeywords: ['tunnel', 'tailscale', 'headscale', 'ngrok', 'serve', 'funnel', 'expose'],
18
+ position: 200,
19
+
20
+ get: async (runtime, _message) => {
21
+ const svc = getTunnelService(runtime);
22
+ if (!svc) {
23
+ return {
24
+ text: 'No tunnel service is registered.',
25
+ data: {
26
+ active: false,
27
+ available: false,
28
+ },
29
+ };
30
+ }
31
+ const status = svc.getStatus();
32
+ const text = status.active
33
+ ? `Tunnel ACTIVE — ${status.url ?? 'unknown URL'} (port ${status.port}, ${status.provider}${status.backend ? `/${status.backend}` : ''})`
34
+ : `Tunnel idle (${status.provider} ready).`;
35
+ return {
36
+ text,
37
+ data: {
38
+ available: true,
39
+ ...status,
40
+ startedAt: status.startedAt ? status.startedAt.toISOString() : null,
41
+ },
42
+ };
43
+ },
44
+ };
45
+
46
+ export default tunnelStateProvider;