@coclaw/openclaw-coclaw 0.5.2 → 0.6.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.
package/README.md CHANGED
@@ -69,9 +69,11 @@ pnpm run release:versions # 显示所有已发布版本
69
69
  ```bash
70
70
  openclaw coclaw bind <binding-code> [--server <url>]
71
71
  openclaw coclaw unbind [--server <url>]
72
+ openclaw coclaw enroll [--server <url>]
72
73
  ```
73
74
 
74
- bind/unbind 成功后会通过 gateway RPC 通知插件刷新/停止 bridge 连接(无需重启 gateway)。若 gateway 未运行,通知会失败但不影响绑定结果。
75
+ - bind/unbind 成功后会通过 gateway RPC 通知插件刷新/停止 bridge 连接(无需重启 gateway)。若 gateway 未运行,通知会失败但不影响绑定结果。
76
+ - enroll 由 OpenClaw 侧主动发起,生成认领码和链接供用户点击完成绑定。已绑定时需先 unbind 再发起。
75
77
 
76
78
  ### 方式二:IM 渠道命令
77
79
 
@@ -80,6 +82,7 @@ bind/unbind 成功后会通过 gateway RPC 通知插件刷新/停止 bridge 连
80
82
  ```
81
83
  /coclaw bind <binding-code> [--server <url>]
82
84
  /coclaw unbind [--server <url>]
85
+ /coclaw enroll [--server <url>]
83
86
  ```
84
87
 
85
88
  需要 gateway 运行中。
package/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import nodePath from 'node:path';
3
3
 
4
- import { bindBot, unbindBot } from './src/common/bot-binding.js';
4
+ import { bindBot, unbindBot, enrollBot, waitForClaimAndSave } from './src/common/bot-binding.js';
5
5
  import { registerCoclawCli } from './src/cli-registrar.js';
6
6
  import { resolveErrorMessage } from './src/common/errors.js';
7
- import { notBound, bindOk, unbindOk } from './src/common/messages.js';
7
+ import { notBound, bindOk, unbindOk, claimCodeCreated } from './src/common/messages.js';
8
8
  import { coclawChannelPlugin } from './src/channel-plugin.js';
9
9
  import { ensureAgentSession, gatewayAgentRpc, restartRealtimeBridge, stopRealtimeBridge, waitForSessionsReady } from './src/realtime-bridge.js';
10
10
  import { setRuntime } from './src/runtime.js';
@@ -59,16 +59,22 @@ function buildHelpText() {
59
59
  '',
60
60
  '/coclaw bind <binding-code> [--server <url>]',
61
61
  '/coclaw unbind [--server <url>]',
62
+ '/coclaw enroll [--server <url>]',
62
63
  ].join('\n');
63
64
  }
64
65
 
65
66
  function respondError(respond, err) {
66
- respond(false, {
67
+ respond(false, undefined, {
68
+ code: err?.code ?? 'INTERNAL_ERROR',
67
69
  /* c8 ignore next */
68
- error: String(err?.message ?? err),
70
+ message: String(err?.message ?? err),
69
71
  });
70
72
  }
71
73
 
74
+ function respondInvalid(respond, message) {
75
+ respond(false, undefined, { code: 'INVALID_INPUT', message });
76
+ }
77
+
72
78
  /* c8 ignore start */
73
79
  const plugin = {
74
80
  id: 'openclaw-coclaw',
@@ -143,6 +149,60 @@ const plugin = {
143
149
  }
144
150
  });
145
151
 
152
+ // enroll 并发控制:同一时刻只允许一个活跃 enroll
153
+ let activeEnrollAbort = null;
154
+
155
+ api.registerGatewayMethod('coclaw.enroll', async ({ params, respond }) => {
156
+ try {
157
+ // 取消前一个 enroll
158
+ if (activeEnrollAbort) {
159
+ activeEnrollAbort.abort();
160
+ }
161
+ const abortController = new AbortController();
162
+ activeEnrollAbort = abortController;
163
+
164
+ const serverUrl = params?.serverUrl ?? api.pluginConfig?.serverUrl;
165
+ const result = await enrollBot({ serverUrl });
166
+
167
+ const rawMinutes = Math.round(
168
+ (new Date(result.expiresAt).getTime() - Date.now()) / 60_000,
169
+ );
170
+ const expiresMinutes = Number.isFinite(rawMinutes) ? rawMinutes : 30;
171
+
172
+ // 立即返回认领码给 CLI
173
+ respond(true, {
174
+ status: {
175
+ code: result.code,
176
+ appUrl: result.appUrl,
177
+ expiresAt: result.expiresAt,
178
+ expiresMinutes,
179
+ },
180
+ });
181
+
182
+ // 后台 fire-and-forget:等待认领并保存 config + 启 bridge
183
+ waitForClaimAndSave({
184
+ serverUrl: result.serverUrl,
185
+ code: result.code,
186
+ waitToken: result.waitToken,
187
+ signal: abortController.signal,
188
+ }).then(async () => {
189
+ if (abortController.signal.aborted) return;
190
+ await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
191
+ logger.info?.('[coclaw] enroll completed, bridge restarted');
192
+ }).catch((err) => {
193
+ if (abortController.signal.aborted) return;
194
+ logger.warn?.(`[coclaw] enroll wait failed: ${String(err?.message ?? err)}`);
195
+ }).finally(() => {
196
+ if (activeEnrollAbort === abortController) {
197
+ activeEnrollAbort = null;
198
+ }
199
+ });
200
+ }
201
+ catch (err) {
202
+ respondError(respond, err);
203
+ }
204
+ });
205
+
146
206
  api.registerGatewayMethod('nativeui.sessions.listAll', async ({ params, respond }) => {
147
207
  try {
148
208
  const agentId = params?.agentId?.trim?.() || 'main';
@@ -169,7 +229,10 @@ const plugin = {
169
229
  try {
170
230
  await waitForSessionsReady();
171
231
  const version = await getPluginVersion();
172
- respond(true, { version, capabilities: ['topics', 'chatHistory'] });
232
+ const rawClawVersion = api.runtime?.version;
233
+ // OpenClaw 打包后 resolveVersion() 路径失配导致返回 'unknown',此时不传该字段
234
+ const clawVersion = (rawClawVersion && rawClawVersion !== 'unknown') ? rawClawVersion : undefined;
235
+ respond(true, { version, clawVersion, capabilities: ['topics', 'chatHistory'] });
173
236
  }
174
237
  catch (err) {
175
238
  respondError(respond, err);
@@ -208,7 +271,7 @@ const plugin = {
208
271
  try {
209
272
  const topicId = params?.topicId?.trim?.();
210
273
  if (!topicId) {
211
- respond(false, { error: 'topicId required' });
274
+ respondInvalid(respond, 'topicId required');
212
275
  return;
213
276
  }
214
277
  respond(true, topicManager.get({ topicId }));
@@ -222,7 +285,7 @@ const plugin = {
222
285
  try {
223
286
  const topicId = params?.topicId?.trim?.();
224
287
  if (!topicId) {
225
- respond(false, { error: 'topicId required' });
288
+ respondInvalid(respond, 'topicId required');
226
289
  return;
227
290
  }
228
291
  const agentId = params?.agentId?.trim?.() || 'main';
@@ -238,23 +301,23 @@ const plugin = {
238
301
  try {
239
302
  const topicId = params?.topicId?.trim?.();
240
303
  if (!topicId) {
241
- respond(false, { error: 'topicId required' });
304
+ respondInvalid(respond, 'topicId required');
242
305
  return;
243
306
  }
244
307
  const changes = params?.changes;
245
308
  if (!changes || typeof changes !== 'object') {
246
- respond(false, { error: 'changes required' });
309
+ respondInvalid(respond, 'changes required');
247
310
  return;
248
311
  }
249
312
  // 当前版本仅处理 title
250
313
  if (typeof changes.title !== 'string') {
251
- respond(false, { error: 'No valid change field provided (supported: title)' });
314
+ respondInvalid(respond, 'No valid change field provided (supported: title)');
252
315
  return;
253
316
  }
254
317
  await topicManager.updateTitle({ topicId, title: changes.title });
255
318
  const { topic } = topicManager.get({ topicId });
256
319
  if (!topic) {
257
- respond(false, { error: `Topic not found: ${topicId}` });
320
+ respondInvalid(respond, `Topic not found: ${topicId}`);
258
321
  return;
259
322
  }
260
323
  respond(true, { topic });
@@ -268,7 +331,7 @@ const plugin = {
268
331
  try {
269
332
  const topicId = params?.topicId?.trim?.();
270
333
  if (!topicId) {
271
- respond(false, { error: 'topicId required' });
334
+ respondInvalid(respond, 'topicId required');
272
335
  return;
273
336
  }
274
337
  const result = await generateTitle({
@@ -288,7 +351,7 @@ const plugin = {
288
351
  try {
289
352
  const topicId = params?.topicId?.trim?.();
290
353
  if (!topicId) {
291
- respond(false, { error: 'topicId required' });
354
+ respondInvalid(respond, 'topicId required');
292
355
  return;
293
356
  }
294
357
  const result = await topicManager.delete({ topicId });
@@ -304,7 +367,7 @@ const plugin = {
304
367
  const agentId = params?.agentId?.trim?.() || 'main';
305
368
  const sessionKey = params?.sessionKey?.trim?.();
306
369
  if (!sessionKey) {
307
- respond(false, { error: 'sessionKey required' });
370
+ respondInvalid(respond, 'sessionKey required');
308
371
  return;
309
372
  }
310
373
  if (!chatHistoryManager.__cache.has(agentId)) {
@@ -323,7 +386,7 @@ const plugin = {
323
386
  try {
324
387
  const sessionId = params?.sessionId?.trim?.();
325
388
  if (!sessionId) {
326
- respond(false, { error: 'sessionId required' });
389
+ respondInvalid(respond, 'sessionId required');
327
390
  return;
328
391
  }
329
392
  const agentId = params?.agentId?.trim?.() || 'main';
@@ -376,6 +439,49 @@ const plugin = {
376
439
  return { text: bindOk(result) };
377
440
  }
378
441
 
442
+ if (action === 'enroll') {
443
+ // 并发控制:取消前一个 enroll(与 RPC 路径共享)
444
+ if (activeEnrollAbort) {
445
+ activeEnrollAbort.abort();
446
+ }
447
+ const abortController = new AbortController();
448
+ activeEnrollAbort = abortController;
449
+
450
+ const serverUrl = options.server ?? api.pluginConfig?.serverUrl;
451
+ const result = await enrollBot({ serverUrl });
452
+ const rawMinutes = Math.round(
453
+ (new Date(result.expiresAt).getTime() - Date.now()) / 60_000,
454
+ );
455
+ const expiresMinutes = Number.isFinite(rawMinutes) ? rawMinutes : 30;
456
+
457
+ // 后台 fire-and-forget:等待认领完成后写 config + 启 bridge
458
+ waitForClaimAndSave({
459
+ serverUrl: result.serverUrl,
460
+ code: result.code,
461
+ waitToken: result.waitToken,
462
+ signal: abortController.signal,
463
+ }).then(async () => {
464
+ if (abortController.signal.aborted) return;
465
+ await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
466
+ logger.info?.('[coclaw] enroll completed via slash command, bridge restarted');
467
+ }).catch((err) => {
468
+ if (abortController.signal.aborted) return;
469
+ logger.warn?.(`[coclaw] enroll wait failed: ${String(err?.message ?? err)}`);
470
+ }).finally(() => {
471
+ if (activeEnrollAbort === abortController) {
472
+ activeEnrollAbort = null;
473
+ }
474
+ });
475
+
476
+ return {
477
+ text: claimCodeCreated({
478
+ code: result.code,
479
+ appUrl: result.appUrl,
480
+ expiresMinutes,
481
+ }),
482
+ };
483
+ }
484
+
379
485
  if (action === 'unbind') {
380
486
  const result = await unbindBot({ serverUrl: options.server });
381
487
  await stopRealtimeBridge();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
package/src/api.js CHANGED
@@ -27,6 +27,8 @@ async function requestJson(baseUrl, path, { method = 'GET', headers, body, timeo
27
27
 
28
28
  const BIND_TIMEOUT = 30_000;
29
29
  const UNBIND_TIMEOUT = 15_000;
30
+ const CLAIM_CODE_TIMEOUT = 15_000;
31
+ const CLAIM_WAIT_TIMEOUT = 30_000;
30
32
 
31
33
  export async function bindWithServer({ baseUrl, code, name }) {
32
34
  return requestJson(baseUrl, '/api/v1/bots/bind', {
@@ -47,3 +49,20 @@ export async function unbindWithServer({ baseUrl, token, timeout = UNBIND_TIMEOU
47
49
  });
48
50
  }
49
51
 
52
+ export async function createClaimCodeOnServer({ baseUrl, timeout = CLAIM_CODE_TIMEOUT }) {
53
+ return requestJson(baseUrl, '/api/v1/claws/claim-codes', {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json' },
56
+ timeout,
57
+ });
58
+ }
59
+
60
+ export async function waitClaimCodeOnServer({ baseUrl, code, waitToken, timeout = CLAIM_WAIT_TIMEOUT }) {
61
+ return requestJson(baseUrl, '/api/v1/claws/claim-codes/wait', {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json' },
64
+ body: { code, waitToken },
65
+ timeout,
66
+ });
67
+ }
68
+
@@ -4,8 +4,45 @@ import { callGatewayMethod } from './common/gateway-notify.js';
4
4
  import {
5
5
  notBound, bindOk, unbindOk,
6
6
  gatewayNotified, gatewayNotifyFailed,
7
+ claimCodeCreated,
7
8
  } from './common/messages.js';
8
9
 
10
+ /**
11
+ * 从 `openclaw gateway call` stderr 中提取核心错误信息
12
+ * stderr 格式:`Gateway call failed: GatewayClientRequestError: <message>`
13
+ */
14
+ function extractRpcErrorMessage(raw) {
15
+ if (!raw) return '';
16
+ const match = raw.match(/GatewayClientRequestError:\s*(.+)/);
17
+ return match ? match[1].trim() : raw;
18
+ }
19
+
20
+ const GATEWAY_UNAVAILABLE_ERRORS = new Set([
21
+ 'spawn_error', 'spawn_failed', 'timeout', 'empty_output',
22
+ ]);
23
+
24
+ function isGatewayUnavailable(result) {
25
+ // exit_code_* 不视为 gateway 不可用:进程已启动成功,非零退出通常是业务错误
26
+ return !result.ok && GATEWAY_UNAVAILABLE_ERRORS.has(result.error);
27
+ }
28
+
29
+ /* c8 ignore start -- 集成级函数,测试通过 deps.restartGateway 注入替代 */
30
+ async function restartGatewayProcess(spawnFn) {
31
+ const { spawn: nodeSpawn } = await import('node:child_process');
32
+ const doSpawn = spawnFn ?? nodeSpawn;
33
+ await new Promise((resolve, reject) => {
34
+ const child = doSpawn('openclaw', ['gateway', 'restart'], {
35
+ stdio: 'ignore',
36
+ shell: process.platform === 'win32',
37
+ });
38
+ child.on('close', (exitCode) => exitCode === 0 ? resolve() : reject(new Error(`exit ${exitCode}`)));
39
+ child.on('error', reject);
40
+ });
41
+ // 等待 gateway 就绪
42
+ await new Promise((r) => setTimeout(r, 3000));
43
+ }
44
+ /* c8 ignore stop */
45
+
9
46
  function resolveServerUrl(opts, config) {
10
47
  return opts?.server
11
48
  ?? config?.plugins?.entries?.['openclaw-coclaw']?.config?.serverUrl
@@ -60,6 +97,61 @@ export function registerCoclawCli({ program, config, logger }, deps = {}) {
60
97
  }
61
98
  });
62
99
 
100
+ coclaw
101
+ .command('enroll')
102
+ .description('Enroll this OpenClaw instance with CoClaw (generate a claim code for the user)')
103
+ .option('--server <url>', 'CoClaw server URL')
104
+ .action(async (opts) => {
105
+ try {
106
+ const rpcOpts = opts?.server ? { params: { serverUrl: opts.server } } : undefined;
107
+ const callRpc = () => callGatewayMethod('coclaw.enroll', deps.spawn, rpcOpts);
108
+
109
+ let result = await callRpc();
110
+
111
+ // 仅在 gateway 不可用时重启重试,业务错误不重启
112
+ if (isGatewayUnavailable(result)) {
113
+ logger.info?.('[coclaw] enroll RPC failed, attempting gateway restart...');
114
+ const restartFn = deps.restartGateway ?? restartGatewayProcess;
115
+ try {
116
+ await restartFn(deps.spawn);
117
+ } catch {
118
+ // 重启失败,仍然尝试再次 RPC
119
+ }
120
+ result = await callRpc();
121
+ }
122
+
123
+ if (!result.ok) {
124
+ if (isGatewayUnavailable(result)) {
125
+ console.error('Error: Could not reach gateway. Ensure OpenClaw gateway is running.');
126
+ console.error(' Try: openclaw gateway start');
127
+ } else {
128
+ // 业务错误(如已绑定):输出 gateway 返回的错误信息
129
+ console.error(`Error: ${extractRpcErrorMessage(result.message) || 'enroll failed'}`);
130
+ }
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+
135
+ // RPC 成功:输出认领码信息
136
+ // gateway method 的 respond 数据包含 status 字段
137
+ const data = result.status;
138
+ if (data?.code && data?.appUrl) {
139
+ console.log(claimCodeCreated({
140
+ code: data.code,
141
+ appUrl: data.appUrl,
142
+ expiresMinutes: data.expiresMinutes ?? 30,
143
+ }));
144
+ } else {
145
+ console.log('Enroll request sent to gateway.');
146
+ }
147
+ }
148
+ /* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
149
+ catch (err) {
150
+ console.error(`Error: ${resolveErrorMessage(err)}`);
151
+ process.exitCode = 1;
152
+ }
153
+ });
154
+
63
155
  coclaw
64
156
  .command('unbind')
65
157
  .description('Unbind this OpenClaw instance from CoClaw')
@@ -1,8 +1,13 @@
1
- import { bindWithServer, unbindWithServer } from '../api.js';
1
+ import { bindWithServer, unbindWithServer, createClaimCodeOnServer, waitClaimCodeOnServer } from '../api.js';
2
2
  import { clearConfig, readConfig, writeConfig } from '../config.js';
3
3
 
4
4
  const DEFAULT_SERVER_URL = 'https://im.coclaw.net';
5
5
 
6
+ function resolveServerUrl(serverUrl) {
7
+ /* c8 ignore next */
8
+ return serverUrl ?? process.env.COCLAW_SERVER_URL ?? DEFAULT_SERVER_URL;
9
+ }
10
+
6
11
  export async function bindBot({ code, serverUrl }) {
7
12
  if (!code) {
8
13
  throw new Error('binding code is required');
@@ -50,6 +55,79 @@ export async function bindBot({ code, serverUrl }) {
50
55
  };
51
56
  }
52
57
 
58
+ export async function enrollBot({ serverUrl }, deps = {}) {
59
+ const { createClaimCode = createClaimCodeOnServer, readCfg = readConfig } = deps;
60
+
61
+ const config = await readCfg();
62
+ if (config?.token) {
63
+ const err = new Error('Already bound. Run `openclaw coclaw unbind` to unbind first, then retry.');
64
+ err.code = 'ALREADY_BOUND';
65
+ throw err;
66
+ }
67
+
68
+ const baseUrl = resolveServerUrl(serverUrl);
69
+ const data = await createClaimCode({ baseUrl });
70
+
71
+ if (!data?.code || !data?.waitToken) {
72
+ throw new Error('invalid enroll response');
73
+ }
74
+
75
+ const appUrl = `${baseUrl}/claim?code=${data.code}`;
76
+ return {
77
+ code: data.code,
78
+ expiresAt: data.expiresAt,
79
+ waitToken: data.waitToken,
80
+ appUrl,
81
+ serverUrl: baseUrl,
82
+ };
83
+ }
84
+
85
+ export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }, deps = {}) {
86
+ const { waitClaimCode = waitClaimCodeOnServer, writeCfg = writeConfig, retryDelayMs = 2000 } = deps;
87
+ const baseUrl = resolveServerUrl(serverUrl);
88
+
89
+ // 循环长轮询,直到成功或超时
90
+ for (;;) {
91
+ if (signal?.aborted) {
92
+ throw new Error('enroll cancelled');
93
+ }
94
+
95
+ let data;
96
+ try {
97
+ data = await waitClaimCode({ baseUrl, code, waitToken });
98
+ } catch (err) {
99
+ // 认领码已失效 — 不可恢复,退出循环
100
+ if (err?.response?.status === 404) {
101
+ throw new Error('claim code not found or expired');
102
+ }
103
+ // 其他所有错误(HTTP 408/500、网络超时、TimeoutError 等)延迟后重试,
104
+ // 确保后台等待不会因瞬时故障而终止
105
+ await new Promise((r) => setTimeout(r, retryDelayMs));
106
+ continue;
107
+ }
108
+
109
+ // 已认领
110
+ if (data?.botId && data?.token) {
111
+ await writeCfg({
112
+ serverUrl: baseUrl,
113
+ botId: data.botId,
114
+ token: data.token,
115
+ boundAt: new Date().toISOString(),
116
+ });
117
+ return { botId: data.botId };
118
+ }
119
+
120
+ // PENDING — 延迟后继续轮询
121
+ if (data?.code === 'CLAIM_PENDING') {
122
+ await new Promise((r) => setTimeout(r, retryDelayMs));
123
+ continue;
124
+ }
125
+
126
+ // 其他未知状态
127
+ throw new Error(`unexpected claim wait response: ${JSON.stringify(data)}`);
128
+ }
129
+ }
130
+
53
131
  export async function unbindBot({ serverUrl }) {
54
132
  const config = await readConfig();
55
133
  if (!config?.token) {
@@ -2,6 +2,15 @@ import { spawn as nodeSpawn } from 'node:child_process';
2
2
 
3
3
  const NOTIFY_TIMEOUT_MS = 10_000;
4
4
  const KILL_DELAY_MS = 2000;
5
+ const IS_WIN = process.platform === 'win32';
6
+
7
+ /**
8
+ * Windows cmd.exe 下转义 JSON 字符串,使其作为单个参数传递
9
+ * 双引号用 \" 转义,外层包裹双引号(与 cross-spawn 策略一致)
10
+ */
11
+ export function escapeJsonForCmd(json) {
12
+ return `"${json.replace(/"/g, '\\"')}"`;
13
+ }
5
14
 
6
15
  /**
7
16
  * 通过 spawn 调用 `openclaw gateway call <method> --json`
@@ -27,9 +36,16 @@ export function callGatewayMethod(method, spawnFn, opts) {
27
36
  return new Promise((resolve) => {
28
37
  let child;
29
38
  try {
30
- child = doSpawn('openclaw', ['gateway', 'call', method, '--json'], {
39
+ const isWin = opts?.isWin ?? IS_WIN;
40
+ const args = ['gateway', 'call', method, '--json'];
41
+ if (opts?.params) {
42
+ const json = JSON.stringify(opts.params);
43
+ // Windows 需 shell 解析 .cmd → 必须转义 JSON;非 Windows 不经 shell,直传
44
+ args.push('--params', isWin ? escapeJsonForCmd(json) : json);
45
+ }
46
+ child = doSpawn('openclaw', args, {
31
47
  stdio: ['ignore', 'pipe', 'pipe'],
32
- shell: true, // Windows npm 全局安装生成 .cmd,需经 shell 解析
48
+ shell: isWin, // Windows shell 以解析 npm 全局安装的 .cmd
33
49
  });
34
50
  } catch {
35
51
  resolve({ ok: false, error: 'spawn_failed' });
@@ -37,6 +53,7 @@ export function callGatewayMethod(method, spawnFn, opts) {
37
53
  }
38
54
 
39
55
  let stdout = '';
56
+ let stderr = '';
40
57
  let settled = false;
41
58
  let graceTimer = null;
42
59
  const killDelayMs = opts?.killDelayMs ?? KILL_DELAY_MS;
@@ -79,7 +96,7 @@ export function callGatewayMethod(method, spawnFn, opts) {
79
96
  }
80
97
  });
81
98
 
82
- child.stderr?.on('data', () => {});
99
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
83
100
 
84
101
  child.on('error', () => finish({ ok: false, error: 'spawn_error' }));
85
102
 
@@ -88,7 +105,8 @@ export function callGatewayMethod(method, spawnFn, opts) {
88
105
  if (code === 0 || stdout.trim()) {
89
106
  finish(parseResult());
90
107
  } else {
91
- finish({ ok: false, error: `exit_code_${code}` });
108
+ const stderrMsg = stderr.trim();
109
+ finish({ ok: false, error: `exit_code_${code}`, message: stderrMsg || undefined });
92
110
  }
93
111
  });
94
112
 
@@ -29,3 +29,13 @@ export function gatewayNotified(action) {
29
29
  export function gatewayNotifyFailed() {
30
30
  return 'Note: could not notify the running gateway. If it is running, restart it manually.';
31
31
  }
32
+
33
+ export function claimCodeCreated({ code, appUrl, expiresMinutes }) {
34
+ return [
35
+ `Claim code: ${code}`,
36
+ `Open this URL to complete binding: ${appUrl}`,
37
+ `The code expires in ${expiresMinutes} minutes.`,
38
+ '',
39
+ "If you don't have a CoClaw account yet, you can register on that page.",
40
+ ].join('\n');
41
+ }