helmpilot 0.4.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 +307 -0
- package/__tests__/config-adapter.test.ts +249 -0
- package/__tests__/outbound.test.ts +147 -0
- package/__tests__/setup-adapter.test.ts +138 -0
- package/adapters.ts +242 -0
- package/bridge.ts +220 -0
- package/channel-plugin.ts +295 -0
- package/index.ts +298 -0
- package/openclaw.plugin.json +67 -0
- package/outbound.ts +98 -0
- package/package.json +38 -0
- package/preload.cjs +33 -0
- package/relay-client.ts +380 -0
- package/relay-registry.ts +32 -0
- package/scripts/link-sdk-core.cjs +124 -0
- package/setup-entry.ts +193 -0
- package/tools.ts +121 -0
- package/types.ts +93 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helmpilot Channel Plugin — ChannelPlugin<ResolvedHelmpilotAccount> definition.
|
|
3
|
+
*
|
|
4
|
+
* Contains: meta, capabilities, config, adapters, gateway (startAccount).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChannelPlugin } from 'openclaw/plugin-sdk/core';
|
|
8
|
+
import { RelayClient } from './relay-client.js';
|
|
9
|
+
import type { RelayConnectionState } from './relay-client.js';
|
|
10
|
+
import { LocalBridge } from './bridge.js';
|
|
11
|
+
import { registerRelay, unregisterRelay } from './relay-registry.js';
|
|
12
|
+
import { createOutboundAdapter } from './outbound.js';
|
|
13
|
+
import {
|
|
14
|
+
createMessagingAdapter,
|
|
15
|
+
createSetupAdapter,
|
|
16
|
+
createStatusAdapter,
|
|
17
|
+
createPairingAdapter,
|
|
18
|
+
createSecurityAdapter,
|
|
19
|
+
} from './adapters.js';
|
|
20
|
+
|
|
21
|
+
// ── Constants ──
|
|
22
|
+
|
|
23
|
+
export const HELMPILOT_CHANNEL_ID = 'helmpilot';
|
|
24
|
+
const HELMPILOT_CONFIG_SECTION = 'channels.helmpilot';
|
|
25
|
+
const DEFAULT_ACCOUNT_ID = 'default';
|
|
26
|
+
|
|
27
|
+
// ── Types ──
|
|
28
|
+
|
|
29
|
+
export interface HelmpilotAccountConfig {
|
|
30
|
+
enabled?: boolean;
|
|
31
|
+
relayUrl?: string;
|
|
32
|
+
channelId?: string;
|
|
33
|
+
channelKey?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResolvedHelmpilotAccount {
|
|
37
|
+
accountId: string;
|
|
38
|
+
config: HelmpilotAccountConfig;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface RawHelmpilotConfig {
|
|
42
|
+
enabled?: boolean;
|
|
43
|
+
relayUrl?: string;
|
|
44
|
+
channelId?: string;
|
|
45
|
+
channelKey?: string | { source: string; id: string; provider?: string };
|
|
46
|
+
accounts?: Record<string, { enabled?: boolean; relayUrl?: string; channelId?: string; channelKey?: string | { source: string; id: string; provider?: string } }>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractHelmpilotConfig(cfg: unknown): RawHelmpilotConfig {
|
|
50
|
+
const section = (cfg as Record<string, unknown>)?.channels as Record<string, unknown> | undefined;
|
|
51
|
+
return (section?.helmpilot as RawHelmpilotConfig) ?? {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Env template pattern: ${ENV_VAR_NAME} */
|
|
55
|
+
const ENV_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a config value that may be a SecretRef or env template.
|
|
59
|
+
* Supports:
|
|
60
|
+
* - Plain string: returned as-is
|
|
61
|
+
* - Env template: "${HELMPILOT_CHANNEL_KEY}" → process.env.HELMPILOT_CHANNEL_KEY
|
|
62
|
+
* - SecretRef object: { source: "env", id: "HELMPILOT_CHANNEL_KEY" } → process.env.HELMPILOT_CHANNEL_KEY
|
|
63
|
+
* - SecretRef file: { source: "file", id: "/path/to/key" } → file contents
|
|
64
|
+
*/
|
|
65
|
+
function resolveSecretValue(value: unknown): string | undefined {
|
|
66
|
+
if (typeof value === 'string') {
|
|
67
|
+
const envMatch = ENV_TEMPLATE_RE.exec(value);
|
|
68
|
+
if (envMatch) {
|
|
69
|
+
const envVal = process.env[envMatch[1]];
|
|
70
|
+
if (!envVal) {
|
|
71
|
+
console.warn(`[helmpilot-channel] Env var ${envMatch[1]} not set for channelKey`);
|
|
72
|
+
}
|
|
73
|
+
return envVal;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
if (typeof value === 'object' && value !== null) {
|
|
78
|
+
const ref = value as { source?: string; id?: string };
|
|
79
|
+
if (ref.source === 'env' && typeof ref.id === 'string') {
|
|
80
|
+
const envVal = process.env[ref.id];
|
|
81
|
+
if (!envVal) {
|
|
82
|
+
console.warn(`[helmpilot-channel] Env var ${ref.id} not set for channelKey`);
|
|
83
|
+
}
|
|
84
|
+
return envVal;
|
|
85
|
+
}
|
|
86
|
+
if (ref.source === 'file' && typeof ref.id === 'string') {
|
|
87
|
+
try {
|
|
88
|
+
const fs = require('node:fs') as typeof import('node:fs');
|
|
89
|
+
return fs.readFileSync(ref.id, 'utf-8').trim();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(`[helmpilot-channel] Failed to read secret file ${ref.id}:`, err);
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Channel Plugin ──
|
|
100
|
+
|
|
101
|
+
export const helmpilotPlugin: ChannelPlugin<ResolvedHelmpilotAccount> = {
|
|
102
|
+
id: HELMPILOT_CHANNEL_ID,
|
|
103
|
+
|
|
104
|
+
meta: {
|
|
105
|
+
id: HELMPILOT_CHANNEL_ID,
|
|
106
|
+
label: 'Helmpilot Desktop',
|
|
107
|
+
selectionLabel: 'Helmpilot Desktop Client',
|
|
108
|
+
docsPath: '/docs/channels/helmpilot',
|
|
109
|
+
blurb: 'Connect to the Helmpilot desktop AI assistant client',
|
|
110
|
+
order: 100,
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
capabilities: {
|
|
114
|
+
chatTypes: ['direct'],
|
|
115
|
+
media: true,
|
|
116
|
+
reactions: false,
|
|
117
|
+
threads: false,
|
|
118
|
+
blockStreaming: true,
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
reload: { configPrefixes: [HELMPILOT_CONFIG_SECTION] },
|
|
122
|
+
|
|
123
|
+
outbound: createOutboundAdapter(),
|
|
124
|
+
messaging: createMessagingAdapter(),
|
|
125
|
+
setup: createSetupAdapter(),
|
|
126
|
+
status: createStatusAdapter(),
|
|
127
|
+
pairing: createPairingAdapter(),
|
|
128
|
+
security: createSecurityAdapter(),
|
|
129
|
+
|
|
130
|
+
config: {
|
|
131
|
+
listAccountIds(cfg) {
|
|
132
|
+
const hp = extractHelmpilotConfig(cfg);
|
|
133
|
+
if (hp.accounts && Object.keys(hp.accounts).length > 0) {
|
|
134
|
+
const ids = Object.keys(hp.accounts);
|
|
135
|
+
if (hp.relayUrl && hp.channelKey && !ids.includes(DEFAULT_ACCOUNT_ID)) {
|
|
136
|
+
ids.unshift(DEFAULT_ACCOUNT_ID);
|
|
137
|
+
}
|
|
138
|
+
return ids;
|
|
139
|
+
}
|
|
140
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
resolveAccount(cfg, accountId) {
|
|
144
|
+
const id = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
145
|
+
const hp = extractHelmpilotConfig(cfg);
|
|
146
|
+
|
|
147
|
+
const acct = hp.accounts?.[id];
|
|
148
|
+
if (acct) {
|
|
149
|
+
return {
|
|
150
|
+
accountId: id,
|
|
151
|
+
config: {
|
|
152
|
+
enabled: acct.enabled !== false && hp.enabled !== false,
|
|
153
|
+
relayUrl: typeof acct.relayUrl === 'string' ? acct.relayUrl : undefined,
|
|
154
|
+
channelId: typeof acct.channelId === 'string' ? acct.channelId : undefined,
|
|
155
|
+
channelKey: resolveSecretValue(acct.channelKey),
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
accountId: id,
|
|
162
|
+
config: {
|
|
163
|
+
enabled: hp.enabled !== false,
|
|
164
|
+
relayUrl: typeof hp.relayUrl === 'string' ? hp.relayUrl : undefined,
|
|
165
|
+
channelId: typeof hp.channelId === 'string' ? hp.channelId : undefined,
|
|
166
|
+
channelKey: resolveSecretValue(hp.channelKey),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
defaultAccountId() {
|
|
172
|
+
return DEFAULT_ACCOUNT_ID;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
isEnabled(account) {
|
|
176
|
+
return account.config.enabled !== false;
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
isConfigured(account) {
|
|
180
|
+
const { relayUrl, channelId, channelKey } = account.config;
|
|
181
|
+
if (relayUrl || channelId || channelKey) {
|
|
182
|
+
return Boolean(relayUrl && channelId && channelKey);
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
setAccountEnabled(params: { cfg: Record<string, unknown>; accountId: string; enabled: boolean }) {
|
|
188
|
+
const cfg = structuredClone(params.cfg);
|
|
189
|
+
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
190
|
+
const hp = (channels.helmpilot ?? {}) as RawHelmpilotConfig;
|
|
191
|
+
|
|
192
|
+
if (hp.accounts?.[params.accountId]) {
|
|
193
|
+
hp.accounts[params.accountId].enabled = params.enabled;
|
|
194
|
+
} else if (params.accountId === DEFAULT_ACCOUNT_ID) {
|
|
195
|
+
hp.enabled = params.enabled;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
channels.helmpilot = hp;
|
|
199
|
+
cfg.channels = channels;
|
|
200
|
+
return cfg;
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
deleteAccount(params: { cfg: Record<string, unknown>; accountId: string }) {
|
|
204
|
+
const cfg = structuredClone(params.cfg);
|
|
205
|
+
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
206
|
+
const hp = (channels.helmpilot ?? {}) as RawHelmpilotConfig;
|
|
207
|
+
|
|
208
|
+
if (hp.accounts?.[params.accountId]) {
|
|
209
|
+
delete hp.accounts[params.accountId];
|
|
210
|
+
} else if (params.accountId === DEFAULT_ACCOUNT_ID) {
|
|
211
|
+
delete channels.helmpilot;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
cfg.channels = channels;
|
|
215
|
+
return cfg;
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
describeAccount(account: ResolvedHelmpilotAccount) {
|
|
219
|
+
return {
|
|
220
|
+
accountId: account.accountId,
|
|
221
|
+
enabled: account.config.enabled !== false,
|
|
222
|
+
configured: Boolean(account.config.relayUrl && account.config.channelId && account.config.channelKey),
|
|
223
|
+
mode: account.config.relayUrl ? 'relay' : 'local',
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
gatewayMethods: ['helmpilot.respond'],
|
|
229
|
+
|
|
230
|
+
gateway: {
|
|
231
|
+
async startAccount(ctx: any) {
|
|
232
|
+
const account = ctx.account as ResolvedHelmpilotAccount;
|
|
233
|
+
const { relayUrl, channelId, channelKey } = account.config;
|
|
234
|
+
|
|
235
|
+
if (!relayUrl || !channelId || !channelKey) {
|
|
236
|
+
console.log('[helmpilot-channel] No relay config, running in direct mode');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const gatewayPort = process.env.OPENCLAW_GATEWAY_PORT || '18789';
|
|
241
|
+
const localGwUrl = `ws://127.0.0.1:${gatewayPort}`;
|
|
242
|
+
|
|
243
|
+
// Read gateway auth token from config, falling back to env var in bridge
|
|
244
|
+
const gatewayToken = typeof ctx.cfg?.gateway?.auth?.token === 'string'
|
|
245
|
+
? ctx.cfg.gateway.auth.token
|
|
246
|
+
: undefined;
|
|
247
|
+
|
|
248
|
+
console.log(`[helmpilot-channel] Starting Relay tunnel to ${relayUrl} (local gw: ${localGwUrl}, token: ${gatewayToken ? 'from-config' : 'from-env-or-none'})`);
|
|
249
|
+
|
|
250
|
+
const bridge = new LocalBridge(localGwUrl, gatewayToken);
|
|
251
|
+
|
|
252
|
+
const relayClient = new RelayClient(
|
|
253
|
+
{ relayUrl, channelId, channelKey, useEd25519: true },
|
|
254
|
+
{
|
|
255
|
+
onRawMessage(data: string) {
|
|
256
|
+
bridge.forward(data);
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
onLifecycleEvent(event: string) {
|
|
260
|
+
console.log(`[helmpilot-channel] Relay lifecycle: ${event}`);
|
|
261
|
+
if (event === 'device_connected') {
|
|
262
|
+
bridge.open(relayClient);
|
|
263
|
+
} else if (event === 'device_disconnected') {
|
|
264
|
+
bridge.close();
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
onStateChange(state: RelayConnectionState) {
|
|
269
|
+
console.log(`[helmpilot-channel] Relay state: ${state}`);
|
|
270
|
+
if (state === 'disconnected' || state === 'error') {
|
|
271
|
+
bridge.close();
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Register gateway Ed25519 public key before connecting
|
|
278
|
+
await relayClient.registerPublicKey();
|
|
279
|
+
|
|
280
|
+
relayClient.start();
|
|
281
|
+
registerRelay(account.accountId, relayClient);
|
|
282
|
+
|
|
283
|
+
const abortSignal = ctx.abortSignal as AbortSignal;
|
|
284
|
+
await new Promise<void>((resolve) => {
|
|
285
|
+
abortSignal.addEventListener('abort', () => {
|
|
286
|
+
console.log('[helmpilot-channel] Stopping Relay tunnel');
|
|
287
|
+
unregisterRelay(account.accountId);
|
|
288
|
+
relayClient.stop();
|
|
289
|
+
bridge.close();
|
|
290
|
+
resolve();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
};
|
package/index.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helmpilot Desktop Channel — OpenClaw plugin entry point
|
|
3
|
+
*
|
|
4
|
+
* This file handles ONLY plugin registration, assembling:
|
|
5
|
+
* - Channel plugin (from channel-plugin.ts)
|
|
6
|
+
* - Gateway RPC methods (helmpilot.respond)
|
|
7
|
+
* - Agent tools (hp_send_file, hp_send_msg, hp_ask_user)
|
|
8
|
+
*
|
|
9
|
+
* Architecture:
|
|
10
|
+
* The gateway loads plugin registration twice — once at connection-level (for
|
|
11
|
+
* gateway methods) and once per-agent (for tools). Because they don't share
|
|
12
|
+
* closure state, we use `globalThis[Symbol.for('helmpilot.pendingMap')]` as a process-level
|
|
13
|
+
* shared mailbox between the helmpilot.respond handler and hp_ask_user execute().
|
|
14
|
+
*
|
|
15
|
+
* Module structure:
|
|
16
|
+
* - index.ts — Plugin registration (this file)
|
|
17
|
+
* - channel-plugin.ts — ChannelPlugin definition (meta/config/gateway)
|
|
18
|
+
* - bridge.ts — LocalBridge class (WS tunnel lifecycle)
|
|
19
|
+
* - relay-client.ts — RelayClient (Relay connection + Ed25519 auth)
|
|
20
|
+
* - tools.ts — Tool schemas + PendingMap
|
|
21
|
+
* - adapters.ts — Channel adapters (messaging/setup/status/pairing/security)
|
|
22
|
+
* - outbound.ts — Outbound message adapter
|
|
23
|
+
* - relay-registry.ts — Per-account RelayClient registry
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
|
|
27
|
+
import { helmpilotPlugin, HELMPILOT_CHANNEL_ID } from './channel-plugin.js';
|
|
28
|
+
import {
|
|
29
|
+
HP_SEND_FILE,
|
|
30
|
+
HP_SEND_MSG,
|
|
31
|
+
HP_ASK_USER,
|
|
32
|
+
HP_READ_FILE,
|
|
33
|
+
HP_WRITE_FILE,
|
|
34
|
+
HP_LIST_DIR,
|
|
35
|
+
SendFileParamsSchema,
|
|
36
|
+
SendMsgParamsSchema,
|
|
37
|
+
AskUserParamsSchema,
|
|
38
|
+
ReadFileParamsSchema,
|
|
39
|
+
WriteFileParamsSchema,
|
|
40
|
+
ListDirParamsSchema,
|
|
41
|
+
getPendingMap,
|
|
42
|
+
} from './tools.js';
|
|
43
|
+
|
|
44
|
+
export default definePluginEntry({
|
|
45
|
+
id: HELMPILOT_CHANNEL_ID,
|
|
46
|
+
name: 'Helmpilot Desktop Channel',
|
|
47
|
+
description:
|
|
48
|
+
'Helmpilot desktop client channel — provides interactive tools and gateway RPC bridge for Helmpilot ↔ OpenClaw communication.',
|
|
49
|
+
|
|
50
|
+
register(api) {
|
|
51
|
+
api.registerChannel({ plugin: helmpilotPlugin });
|
|
52
|
+
|
|
53
|
+
// ── Timeout helper for client-blocking tool calls ──
|
|
54
|
+
const FILE_TOOL_TIMEOUT_MS = 30_000;
|
|
55
|
+
|
|
56
|
+
function waitForClientResponse(toolCallId: string, params: unknown, timeoutMs: number): Promise<string> {
|
|
57
|
+
const pendingMap = getPendingMap();
|
|
58
|
+
const stale = pendingMap.get(toolCallId);
|
|
59
|
+
if (stale) {
|
|
60
|
+
stale.resolve('[Superseded by a new request.]');
|
|
61
|
+
pendingMap.delete(toolCallId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return new Promise<string>((resolve) => {
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
pendingMap.delete(toolCallId);
|
|
67
|
+
resolve('[Error] Client did not respond within timeout.');
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
|
|
70
|
+
pendingMap.set(toolCallId, {
|
|
71
|
+
resolve: (answer: string) => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
resolve(answer);
|
|
74
|
+
},
|
|
75
|
+
questions: params,
|
|
76
|
+
toolCallId,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Gateway RPC: helmpilot.respond ──
|
|
82
|
+
|
|
83
|
+
api.registerGatewayMethod(
|
|
84
|
+
'helmpilot.respond',
|
|
85
|
+
({ params, respond }) => {
|
|
86
|
+
const toolCallId = typeof params?.toolCallId === 'string' ? params.toolCallId : '';
|
|
87
|
+
const answers = typeof params?.answers === 'string' ? params.answers : '';
|
|
88
|
+
if (!answers) {
|
|
89
|
+
respond(false, undefined, { code: 'INVALID_REQUEST', message: 'Missing answers string' });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!toolCallId) {
|
|
93
|
+
respond(false, undefined, { code: 'INVALID_REQUEST', message: 'Missing toolCallId — required for account isolation' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const pendingMap = getPendingMap();
|
|
97
|
+
const pending = pendingMap.get(toolCallId);
|
|
98
|
+
if (!pending) {
|
|
99
|
+
respond(false, undefined, { code: 'NOT_FOUND', message: 'No pending hp_ask_user request for this toolCallId' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
pendingMap.delete(toolCallId);
|
|
103
|
+
pending.resolve(answers);
|
|
104
|
+
respond(true, { status: 'ok' });
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// ── Tool: hp_send_file ──
|
|
109
|
+
|
|
110
|
+
api.registerTool({
|
|
111
|
+
name: HP_SEND_FILE,
|
|
112
|
+
description: [
|
|
113
|
+
'Send a single file to the Helmpilot desktop client.',
|
|
114
|
+
'The client receives the filename and content via tool events and handles',
|
|
115
|
+
'display and writing locally. Call once per file.',
|
|
116
|
+
'This tool does NOT write files on the server — it only relays content to the client.',
|
|
117
|
+
].join('\n'),
|
|
118
|
+
label: 'Send File to Helmpilot',
|
|
119
|
+
parameters: SendFileParamsSchema,
|
|
120
|
+
|
|
121
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
122
|
+
const p = params as { filename?: string; content?: string };
|
|
123
|
+
return {
|
|
124
|
+
content: [{ type: 'text' as const, text: `File "${p.filename ?? 'unknown'}" sent to Helmpilot client.` }],
|
|
125
|
+
details: {},
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── Tool: hp_send_msg ──
|
|
131
|
+
|
|
132
|
+
api.registerTool({
|
|
133
|
+
name: HP_SEND_MSG,
|
|
134
|
+
description: [
|
|
135
|
+
'Send a notification or structured message to the Helmpilot desktop client.',
|
|
136
|
+
'Use this for status updates, alerts, confirmations, or formatted content',
|
|
137
|
+
'that should appear as a distinct notification rather than inline chat text.',
|
|
138
|
+
'Supports Markdown in the message body.',
|
|
139
|
+
'',
|
|
140
|
+
'Types: "info" (default), "success", "warning", "error".',
|
|
141
|
+
].join('\n'),
|
|
142
|
+
label: 'Send Message to Helmpilot',
|
|
143
|
+
parameters: SendMsgParamsSchema,
|
|
144
|
+
|
|
145
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
146
|
+
const p = params as { message?: string; type?: string; title?: string };
|
|
147
|
+
const msgType = p.type ?? 'info';
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: 'text' as const, text: `[${msgType}] message delivered to Helmpilot client.` }],
|
|
150
|
+
details: {},
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── Tool: hp_ask_user ──
|
|
156
|
+
|
|
157
|
+
api.registerTool({
|
|
158
|
+
name: HP_ASK_USER,
|
|
159
|
+
description: [
|
|
160
|
+
'Ask the user one or more questions with an interactive UI in the Helmpilot client.',
|
|
161
|
+
'Each question can have predefined selectable options (single or multi-select),',
|
|
162
|
+
'allow freeform text input, or both.',
|
|
163
|
+
'The tool blocks until the user submits answers and returns them as text.',
|
|
164
|
+
'',
|
|
165
|
+
'Use this when you need clarification, preferences, or structured input.',
|
|
166
|
+
].join('\n'),
|
|
167
|
+
label: 'Ask User',
|
|
168
|
+
parameters: AskUserParamsSchema,
|
|
169
|
+
|
|
170
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
171
|
+
try {
|
|
172
|
+
const pendingMap = getPendingMap();
|
|
173
|
+
|
|
174
|
+
const stale = pendingMap.get(_toolCallId);
|
|
175
|
+
if (stale) {
|
|
176
|
+
stale.resolve('[Superseded by a new hp_ask_user call.]');
|
|
177
|
+
pendingMap.delete(_toolCallId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const typedParams = params as { questions?: unknown[] };
|
|
181
|
+
|
|
182
|
+
// Wait indefinitely until the user responds.
|
|
183
|
+
// Natural cleanup boundaries: conversation end, gateway restart, relay flush expiry.
|
|
184
|
+
const answer = await new Promise<string>((resolve) => {
|
|
185
|
+
pendingMap.set(_toolCallId, {
|
|
186
|
+
resolve,
|
|
187
|
+
questions: typedParams.questions ?? [],
|
|
188
|
+
toolCallId: _toolCallId,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
content: [{ type: 'text' as const, text: answer }],
|
|
194
|
+
details: {},
|
|
195
|
+
};
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error('[hp_ask_user] execute() error:', err);
|
|
198
|
+
return {
|
|
199
|
+
content: [{ type: 'text' as const, text: `[hp_ask_user error: ${err}]` }],
|
|
200
|
+
details: {},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ── Tool: hp_read_file ──
|
|
207
|
+
|
|
208
|
+
api.registerTool({
|
|
209
|
+
name: HP_READ_FILE,
|
|
210
|
+
description: [
|
|
211
|
+
'Read a file from the user\'s local workspace on the Helmpilot desktop client.',
|
|
212
|
+
'The path must be relative to the workspace root (e.g. "src/main.ts").',
|
|
213
|
+
'Absolute paths and path traversal (../) are rejected.',
|
|
214
|
+
'Files larger than 1 MB will return an error.',
|
|
215
|
+
'The tool blocks until the client reads the file and returns its content.',
|
|
216
|
+
].join('\n'),
|
|
217
|
+
label: 'Read File from Workspace',
|
|
218
|
+
parameters: ReadFileParamsSchema,
|
|
219
|
+
|
|
220
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
221
|
+
try {
|
|
222
|
+
const answer = await waitForClientResponse(_toolCallId, params, FILE_TOOL_TIMEOUT_MS);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
content: [{ type: 'text' as const, text: answer }],
|
|
226
|
+
details: {},
|
|
227
|
+
};
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: 'text' as const, text: `[hp_read_file error: ${err}]` }],
|
|
231
|
+
details: {},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ── Tool: hp_write_file ──
|
|
238
|
+
|
|
239
|
+
api.registerTool({
|
|
240
|
+
name: HP_WRITE_FILE,
|
|
241
|
+
description: [
|
|
242
|
+
'Write content to a file in the user\'s local workspace on the Helmpilot desktop client.',
|
|
243
|
+
'The path must be relative to the workspace root (e.g. "docs/notes.md").',
|
|
244
|
+
'Creates the file if it does not exist; overwrites if it does.',
|
|
245
|
+
'Parent directories are created automatically.',
|
|
246
|
+
'Absolute paths and path traversal (../) are rejected.',
|
|
247
|
+
].join('\n'),
|
|
248
|
+
label: 'Write File to Workspace',
|
|
249
|
+
parameters: WriteFileParamsSchema,
|
|
250
|
+
|
|
251
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
252
|
+
try {
|
|
253
|
+
const answer = await waitForClientResponse(_toolCallId, params, FILE_TOOL_TIMEOUT_MS);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
content: [{ type: 'text' as const, text: answer }],
|
|
257
|
+
details: {},
|
|
258
|
+
};
|
|
259
|
+
} catch (err) {
|
|
260
|
+
return {
|
|
261
|
+
content: [{ type: 'text' as const, text: `[hp_write_file error: ${err}]` }],
|
|
262
|
+
details: {},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── Tool: hp_list_dir ──
|
|
269
|
+
|
|
270
|
+
api.registerTool({
|
|
271
|
+
name: HP_LIST_DIR,
|
|
272
|
+
description: [
|
|
273
|
+
'List the contents of a directory in the user\'s local workspace on the Helmpilot desktop client.',
|
|
274
|
+
'The path must be relative to the workspace root. Use "" or "." for the root directory.',
|
|
275
|
+
'Returns a JSON array of entries, each with name, type ("file" or "directory"), and size.',
|
|
276
|
+
'Absolute paths and path traversal (../) are rejected.',
|
|
277
|
+
].join('\n'),
|
|
278
|
+
label: 'List Directory in Workspace',
|
|
279
|
+
parameters: ListDirParamsSchema,
|
|
280
|
+
|
|
281
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
282
|
+
try {
|
|
283
|
+
const answer = await waitForClientResponse(_toolCallId, params, FILE_TOOL_TIMEOUT_MS);
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
content: [{ type: 'text' as const, text: answer }],
|
|
287
|
+
details: {},
|
|
288
|
+
};
|
|
289
|
+
} catch (err) {
|
|
290
|
+
return {
|
|
291
|
+
content: [{ type: 'text' as const, text: `[hp_list_dir error: ${err}]` }],
|
|
292
|
+
details: {},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "helmpilot",
|
|
3
|
+
"name": "Helmpilot Desktop Channel",
|
|
4
|
+
"description": "Helmpilot desktop client channel plugin — provides interactive tools (ask_user, send_msg, send_file) and gateway RPC methods for Helmpilot \u2194 OpenClaw communication.",
|
|
5
|
+
"version": "0.4.0",
|
|
6
|
+
"channels": ["helmpilot"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"enabled": {
|
|
12
|
+
"type": "boolean",
|
|
13
|
+
"description": "Enable or disable the Helmpilot channel globally",
|
|
14
|
+
"default": true
|
|
15
|
+
},
|
|
16
|
+
"relayUrl": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "(Legacy single-account) Relay URL — prefer using accounts.{id}.relayUrl"
|
|
19
|
+
},
|
|
20
|
+
"channelKey": {
|
|
21
|
+
"oneOf": [
|
|
22
|
+
{ "type": "string" },
|
|
23
|
+
{
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"source": { "type": "string", "enum": ["env", "file"] },
|
|
27
|
+
"provider": { "type": "string" },
|
|
28
|
+
"id": { "type": "string" }
|
|
29
|
+
},
|
|
30
|
+
"required": ["source", "id"]
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"description": "(Legacy single-account) Channel key. Supports: plain string, \"${ENV_VAR}\", or SecretRef {source,id}."
|
|
34
|
+
},
|
|
35
|
+
"accounts": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"description": "Named accounts (one per device). Each key is an account ID, value has relayUrl + channelKey.",
|
|
38
|
+
"additionalProperties": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"enabled": { "type": "boolean", "default": true },
|
|
42
|
+
"relayUrl": { "type": "string" },
|
|
43
|
+
"channelKey": {
|
|
44
|
+
"oneOf": [
|
|
45
|
+
{ "type": "string" },
|
|
46
|
+
{
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"source": { "type": "string", "enum": ["env", "file"] },
|
|
50
|
+
"provider": { "type": "string" },
|
|
51
|
+
"id": { "type": "string" }
|
|
52
|
+
},
|
|
53
|
+
"required": ["source", "id"]
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"description": "Channel key. Supports: plain string, \"${ENV_VAR}\", or SecretRef {source,id}."
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"required": ["relayUrl", "channelKey"]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"contracts": {
|
|
65
|
+
"tools": ["hp_ask_user", "hp_send_msg", "hp_send_file"]
|
|
66
|
+
}
|
|
67
|
+
}
|