@coclaw/openclaw-coclaw 0.5.2 → 0.6.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/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';
@@ -208,7 +268,7 @@ const plugin = {
208
268
  try {
209
269
  const topicId = params?.topicId?.trim?.();
210
270
  if (!topicId) {
211
- respond(false, { error: 'topicId required' });
271
+ respondInvalid(respond, 'topicId required');
212
272
  return;
213
273
  }
214
274
  respond(true, topicManager.get({ topicId }));
@@ -222,7 +282,7 @@ const plugin = {
222
282
  try {
223
283
  const topicId = params?.topicId?.trim?.();
224
284
  if (!topicId) {
225
- respond(false, { error: 'topicId required' });
285
+ respondInvalid(respond, 'topicId required');
226
286
  return;
227
287
  }
228
288
  const agentId = params?.agentId?.trim?.() || 'main';
@@ -238,23 +298,23 @@ const plugin = {
238
298
  try {
239
299
  const topicId = params?.topicId?.trim?.();
240
300
  if (!topicId) {
241
- respond(false, { error: 'topicId required' });
301
+ respondInvalid(respond, 'topicId required');
242
302
  return;
243
303
  }
244
304
  const changes = params?.changes;
245
305
  if (!changes || typeof changes !== 'object') {
246
- respond(false, { error: 'changes required' });
306
+ respondInvalid(respond, 'changes required');
247
307
  return;
248
308
  }
249
309
  // 当前版本仅处理 title
250
310
  if (typeof changes.title !== 'string') {
251
- respond(false, { error: 'No valid change field provided (supported: title)' });
311
+ respondInvalid(respond, 'No valid change field provided (supported: title)');
252
312
  return;
253
313
  }
254
314
  await topicManager.updateTitle({ topicId, title: changes.title });
255
315
  const { topic } = topicManager.get({ topicId });
256
316
  if (!topic) {
257
- respond(false, { error: `Topic not found: ${topicId}` });
317
+ respondInvalid(respond, `Topic not found: ${topicId}`);
258
318
  return;
259
319
  }
260
320
  respond(true, { topic });
@@ -268,7 +328,7 @@ const plugin = {
268
328
  try {
269
329
  const topicId = params?.topicId?.trim?.();
270
330
  if (!topicId) {
271
- respond(false, { error: 'topicId required' });
331
+ respondInvalid(respond, 'topicId required');
272
332
  return;
273
333
  }
274
334
  const result = await generateTitle({
@@ -288,7 +348,7 @@ const plugin = {
288
348
  try {
289
349
  const topicId = params?.topicId?.trim?.();
290
350
  if (!topicId) {
291
- respond(false, { error: 'topicId required' });
351
+ respondInvalid(respond, 'topicId required');
292
352
  return;
293
353
  }
294
354
  const result = await topicManager.delete({ topicId });
@@ -304,7 +364,7 @@ const plugin = {
304
364
  const agentId = params?.agentId?.trim?.() || 'main';
305
365
  const sessionKey = params?.sessionKey?.trim?.();
306
366
  if (!sessionKey) {
307
- respond(false, { error: 'sessionKey required' });
367
+ respondInvalid(respond, 'sessionKey required');
308
368
  return;
309
369
  }
310
370
  if (!chatHistoryManager.__cache.has(agentId)) {
@@ -323,7 +383,7 @@ const plugin = {
323
383
  try {
324
384
  const sessionId = params?.sessionId?.trim?.();
325
385
  if (!sessionId) {
326
- respond(false, { error: 'sessionId required' });
386
+ respondInvalid(respond, 'sessionId required');
327
387
  return;
328
388
  }
329
389
  const agentId = params?.agentId?.trim?.() || 'main';
@@ -376,6 +436,49 @@ const plugin = {
376
436
  return { text: bindOk(result) };
377
437
  }
378
438
 
439
+ if (action === 'enroll') {
440
+ // 并发控制:取消前一个 enroll(与 RPC 路径共享)
441
+ if (activeEnrollAbort) {
442
+ activeEnrollAbort.abort();
443
+ }
444
+ const abortController = new AbortController();
445
+ activeEnrollAbort = abortController;
446
+
447
+ const serverUrl = options.server ?? api.pluginConfig?.serverUrl;
448
+ const result = await enrollBot({ serverUrl });
449
+ const rawMinutes = Math.round(
450
+ (new Date(result.expiresAt).getTime() - Date.now()) / 60_000,
451
+ );
452
+ const expiresMinutes = Number.isFinite(rawMinutes) ? rawMinutes : 30;
453
+
454
+ // 后台 fire-and-forget:等待认领完成后写 config + 启 bridge
455
+ waitForClaimAndSave({
456
+ serverUrl: result.serverUrl,
457
+ code: result.code,
458
+ waitToken: result.waitToken,
459
+ signal: abortController.signal,
460
+ }).then(async () => {
461
+ if (abortController.signal.aborted) return;
462
+ await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
463
+ logger.info?.('[coclaw] enroll completed via slash command, bridge restarted');
464
+ }).catch((err) => {
465
+ if (abortController.signal.aborted) return;
466
+ logger.warn?.(`[coclaw] enroll wait failed: ${String(err?.message ?? err)}`);
467
+ }).finally(() => {
468
+ if (activeEnrollAbort === abortController) {
469
+ activeEnrollAbort = null;
470
+ }
471
+ });
472
+
473
+ return {
474
+ text: claimCodeCreated({
475
+ code: result.code,
476
+ appUrl: result.appUrl,
477
+ expiresMinutes,
478
+ }),
479
+ };
480
+ }
481
+
379
482
  if (action === 'unbind') {
380
483
  const result = await unbindBot({ serverUrl: options.server });
381
484
  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.0",
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
+ }