echoclaw-relay-agent 0.22.4 → 0.22.6

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.
@@ -469,11 +469,20 @@ export class RelayAgent extends EventEmitter {
469
469
  this.transport.on('connect_timeout', (timeoutMs) => {
470
470
  this.emit('error', Object.assign(new Error(`Connection timed out after ${timeoutMs / 1000}s`), { code: 'CONNECT_TIMEOUT' }));
471
471
  });
472
- // Handle server fatal CLOSE messages (SESSION_NOT_FOUND, SESSION_PROTOCOL_MISMATCH, etc.)
472
+ // Handle server messages (DESKTOP_RECONNECTED, fatal errors, etc.)
473
473
  const SESSION_FATAL = new Set(['SESSION_NOT_FOUND', 'INVALID_SESSION', 'SESSION_PROTOCOL_MISMATCH']);
474
474
  this.transport.on('message', (msg) => {
475
475
  if ((msg.type === 'CLOSE' || msg.type === 'STATUS') && msg.sender_role === 'server') {
476
476
  const payload = typeof msg.payload === 'string' ? msg.payload : '';
477
+ // Desktop restarted — reset FrameCrypto receive sequence counter.
478
+ // Desktop creates a new FrameCrypto on resumeSession() (sendSeq starts at 0),
479
+ // but Agent's FrameCrypto still has recvHighestSeq from the old session.
480
+ // Without this reset, all messages from Desktop are rejected as "Sequence regression".
481
+ if (payload === 'DESKTOP_RECONNECTED') {
482
+ this.frameCrypto?.reset();
483
+ this.emit('peer_reconnected');
484
+ return;
485
+ }
477
486
  if (payload === 'UNPAIRED' || SESSION_FATAL.has(payload)) {
478
487
  this.sessionStore.clear().catch(() => { });
479
488
  this._paired = false;
@@ -399,6 +399,14 @@ export class RelayClient extends EventEmitter {
399
399
  this.transport.on('message', (msg) => {
400
400
  if ((msg.type === 'CLOSE' || msg.type === 'STATUS') && msg.sender_role === 'server') {
401
401
  const payload = typeof msg.payload === 'string' ? msg.payload : '';
402
+ // Agent restarted — reset FrameCrypto receive sequence counter.
403
+ // Same issue as DESKTOP_RECONNECTED: Agent creates new FrameCrypto on restart
404
+ // (sendSeq starts at 0), but Desktop's recvHighestSeq is still from old session.
405
+ if (payload === 'AGENT_RESTARTED') {
406
+ this.frameCrypto?.reset();
407
+ this.emit('peer_reconnected');
408
+ return;
409
+ }
402
410
  if (PEER_STATUS.has(payload)) {
403
411
  this.emit('peer_status', {
404
412
  status: payload === 'AGENT_ONLINE' ? 'online' : 'warning',
package/dist/cli.js CHANGED
@@ -97,6 +97,9 @@ function parseArgs(argv) {
97
97
  else if (arg === 'status') {
98
98
  command = 'status';
99
99
  }
100
+ else if (arg === 'restart') {
101
+ command = 'restart';
102
+ }
100
103
  else if (arg === 'uninstall') {
101
104
  command = 'uninstall';
102
105
  }
@@ -171,6 +174,7 @@ ${BOLD}EchoClaw Relay Agent${RESET}
171
174
  ${BOLD}Usage:${RESET}
172
175
  ${CYAN}echoclaw-relay setup <CODE>${RESET} Pair + install as system service (${BOLD}recommended${RESET})
173
176
  ${CYAN}echoclaw-relay status${RESET} Show service status
177
+ ${CYAN}echoclaw-relay restart${RESET} Restart system service
174
178
  ${CYAN}echoclaw-relay uninstall${RESET} Remove system service
175
179
 
176
180
  ${BOLD}Setup Options:${RESET}
@@ -423,6 +427,31 @@ async function runStatus() {
423
427
  }
424
428
  console.log();
425
429
  }
430
+ async function runRestart() {
431
+ printLogo();
432
+ const svc = await getServiceManager();
433
+ const st = await svc.status();
434
+ if (!st.installed) {
435
+ console.log(` ${RED}Service not installed.${RESET}`);
436
+ console.log(` Run ${CYAN}echoclaw-relay setup <CODE>${RESET} first.\n`);
437
+ process.exit(1);
438
+ }
439
+ console.log(` Restarting service...`);
440
+ await svc.restart();
441
+ // Brief wait for process to come up
442
+ await new Promise(r => setTimeout(r, 1500));
443
+ const after = await svc.status();
444
+ if (after.running) {
445
+ console.log(` ${GREEN}${BOLD}Service restarted successfully.${RESET}`);
446
+ if (after.pid)
447
+ console.log(` PID: ${after.pid}`);
448
+ }
449
+ else {
450
+ console.log(` ${YELLOW}Service restarted but may still be starting up.${RESET}`);
451
+ console.log(` Run ${CYAN}echoclaw-relay status${RESET} to check.`);
452
+ }
453
+ console.log();
454
+ }
426
455
  async function runUninstall() {
427
456
  printLogo();
428
457
  const svc = await getServiceManager();
@@ -745,6 +774,9 @@ async function main() {
745
774
  else if (opts.command === 'status') {
746
775
  await runStatus();
747
776
  }
777
+ else if (opts.command === 'restart') {
778
+ await runRestart();
779
+ }
748
780
  else if (opts.command === 'uninstall') {
749
781
  await runUninstall();
750
782
  }
@@ -44,6 +44,10 @@ export declare class InstallHandler {
44
44
  private _runToRequest;
45
45
  /** Whether workspace docs have been synced to OpenClaw. */
46
46
  private _workspaceSynced;
47
+ /** ICP workspace orchestrator for engine-based flows. */
48
+ private readonly _icpOrchestrator;
49
+ /** Actions that should be routed through ICP workspace flow. */
50
+ private static readonly ICP_ACTIONS;
47
51
  constructor(wsClient: OpenClawWsClient, chatHandler: ChatHandler, config?: InstallHandlerConfig);
48
52
  /** Set the callback for sending messages back to Desktop. */
49
53
  setSendBack(fn: (msg: Record<string, unknown>) => Promise<void>): void;
@@ -62,6 +66,25 @@ export declare class InstallHandler {
62
66
  handleAbort(requestId: string): Promise<void>;
63
67
  /** Disconnect and clean up all active installs. */
64
68
  cleanup(): void;
69
+ /**
70
+ * Handle an install request through the ICP workspace orchestrator.
71
+ * On timeout, falls back to the chat-based flow.
72
+ */
73
+ private _handleViaIcp;
74
+ /**
75
+ * Build an IcpRequest from an InstallRequest payload.
76
+ * Returns null if the payload can't be mapped to an ICP request.
77
+ */
78
+ private _buildIcpRequest;
79
+ /**
80
+ * Fall back to the existing chat-based flow.
81
+ * Called when ICP times out or can't handle the request.
82
+ */
83
+ private _handleViaChatFallback;
84
+ /**
85
+ * Generate human-readable message for ICP progress phases.
86
+ */
87
+ private _icpPhaseMessage;
65
88
  /**
66
89
  * Handle a streaming chat event for an install run.
67
90
  * Called when a chat event arrives with a runId we're tracking.
@@ -23,6 +23,8 @@ import crypto from 'node:crypto';
23
23
  import { buildAppRequestPrompt } from '../gateway/AppRequestPrompt.js';
24
24
  import { StreamingMarkerParser } from './MarkerParser.js';
25
25
  import { INSTALL_ERROR_CODES, } from './types.js';
26
+ import { IcpOrchestrator, } from '@echoclaw/icp-orchestrator';
27
+ import { resolveInstallAction, } from '@echoclaw/claw-engine';
26
28
  // ── Constants ────────────────────────────────────────────────────
27
29
  const DEFAULT_TIMEOUT_MS = 120000;
28
30
  const DEFAULT_RPC_TIMEOUT_MS = 30000;
@@ -90,11 +92,23 @@ export class InstallHandler {
90
92
  writable: true,
91
93
  value: false
92
94
  });
95
+ /** ICP workspace orchestrator for engine-based flows. */
96
+ Object.defineProperty(this, "_icpOrchestrator", {
97
+ enumerable: true,
98
+ configurable: true,
99
+ writable: true,
100
+ value: void 0
101
+ });
93
102
  this._wsClient = wsClient;
94
103
  this._chatHandler = chatHandler;
95
104
  this._timeoutMs = config?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
96
105
  this._sessionKey = config?.sessionKey ?? DEFAULT_SESSION_KEY;
97
106
  this._rpcTimeoutMs = config?.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
107
+ // Initialize ICP orchestrator
108
+ this._icpOrchestrator = new IcpOrchestrator({
109
+ outputTimeoutMs: this._timeoutMs,
110
+ reviewTimeoutMs: this._timeoutMs,
111
+ });
98
112
  // Listen for chat events — only process runs we own
99
113
  this._wsClient.on('chat', (payload) => {
100
114
  const runId = payload?.runId;
@@ -149,6 +163,41 @@ export class InstallHandler {
149
163
  requestId,
150
164
  status: 'accepted',
151
165
  });
166
+ // ── ICP workspace routing ────────────────────────────────────
167
+ // Route eligible actions through ICP orchestrator (workspace-based).
168
+ // Falls back to chat-based on timeout/failure.
169
+ if (InstallHandler.ICP_ACTIONS.has(action) && this._workspaceSynced) {
170
+ // Track in _activeInstalls to prevent duplicates and support abort
171
+ const icpAbort = new AbortController();
172
+ const icpInstall = {
173
+ requestId,
174
+ action,
175
+ runId: null,
176
+ parser: new StreamingMarkerParser(),
177
+ startedAt: Date.now(),
178
+ timeoutTimer: null,
179
+ lastKnownLength: 0,
180
+ legacy,
181
+ lastStatusAt: 0,
182
+ streamMarkers: [],
183
+ hasConfirmedPlan: false,
184
+ icpAbort,
185
+ };
186
+ this._activeInstalls.set(requestId, icpInstall);
187
+ this._handleViaIcp(requestId, action, payload, icpAbort.signal).catch((err) => {
188
+ // ICP flow threw unexpectedly — send failure to Desktop
189
+ this._send({
190
+ type: 'install_result',
191
+ requestId,
192
+ status: 'failed',
193
+ error: err?.message || 'ICP orchestration failed unexpectedly',
194
+ code: INSTALL_ERROR_CODES.ICP_FAILED,
195
+ }).catch(() => { });
196
+ this._activeInstalls.delete(requestId);
197
+ });
198
+ return;
199
+ }
200
+ // ── Chat-based flow (legacy / preview / execute-with-plan) ──
152
201
  // Create active install tracker
153
202
  const install = {
154
203
  requestId,
@@ -162,6 +211,7 @@ export class InstallHandler {
162
211
  lastStatusAt: 0,
163
212
  streamMarkers: [],
164
213
  hasConfirmedPlan: !!(action === 'install' && payload.confirmedPlan),
214
+ icpAbort: null,
165
215
  };
166
216
  this._activeInstalls.set(requestId, install);
167
217
  // Build prompt from the install/adapt/preview payload
@@ -224,7 +274,19 @@ export class InstallHandler {
224
274
  const install = this._activeInstalls.get(requestId);
225
275
  if (!install)
226
276
  return;
227
- // Try to abort the AI generation
277
+ // If this is an ICP flow, abort via AbortController
278
+ if (install.icpAbort) {
279
+ install.icpAbort.abort();
280
+ this._send({
281
+ type: 'install_result',
282
+ requestId,
283
+ status: 'cancelled',
284
+ code: INSTALL_ERROR_CODES.CANCELLED,
285
+ }).catch(() => { });
286
+ this._activeInstalls.delete(requestId);
287
+ return;
288
+ }
289
+ // Chat-based flow: try to abort the AI generation
228
290
  try {
229
291
  await this._wsClient.request('chat.abort', {
230
292
  sessionKey: this._sessionKey,
@@ -240,12 +302,246 @@ export class InstallHandler {
240
302
  }
241
303
  /** Disconnect and clean up all active installs. */
242
304
  cleanup() {
243
- for (const [requestId] of this._activeInstalls) {
305
+ for (const [requestId, install] of this._activeInstalls) {
306
+ // Abort any active ICP workspace flows
307
+ if (install.icpAbort) {
308
+ install.icpAbort.abort();
309
+ }
244
310
  this._cleanupInstall(requestId);
245
311
  }
246
312
  this._activeInstalls.clear();
247
313
  this._runToRequest.clear();
248
314
  }
315
+ // ── Private: ICP Workspace Flow ─────────────────────────────────
316
+ /**
317
+ * Handle an install request through the ICP workspace orchestrator.
318
+ * On timeout, falls back to the chat-based flow.
319
+ */
320
+ async _handleViaIcp(requestId, action, payload, signal) {
321
+ // Build ICP request from install payload
322
+ const icpRequest = this._buildIcpRequest(action, payload);
323
+ if (!icpRequest) {
324
+ // Can't build ICP request — fall back to chat
325
+ await this._handleViaChatFallback(requestId, payload);
326
+ return;
327
+ }
328
+ // Map ICP progress to InstallStatus
329
+ const onProgress = (progress) => {
330
+ const phaseMap = {
331
+ writing_task: 'icp_writing_task',
332
+ waiting_output: 'icp_waiting_output',
333
+ validating: 'icp_validating',
334
+ writing_review: 'icp_reviewing',
335
+ waiting_revision: 'icp_reviewing',
336
+ fallback: 'icp_fallback',
337
+ delivering: 'icp_delivering',
338
+ };
339
+ const installPhase = phaseMap[progress.phase] ?? 'ai_processing';
340
+ const message = progress.message ?? this._icpPhaseMessage(progress);
341
+ this._sendStatus(requestId, installPhase, message).catch(() => { });
342
+ };
343
+ // Notify OpenClaw about new tasks/reviews via chat
344
+ const notifyAI = async (taskId, content, isReview) => {
345
+ try {
346
+ const message = isReview
347
+ ? `Review feedback written for task ${taskId}. Read the review file and fix the issues, then rewrite output/package.claw.json.`
348
+ : `New task available: ${taskId}. Read the task file at claw-apps/${taskId}/task.md and follow the instructions.`;
349
+ const response = await this._wsClient.request('chat.send', {
350
+ sessionKey: this._sessionKey,
351
+ message,
352
+ idempotencyKey: crypto.randomUUID(),
353
+ }, this._rpcTimeoutMs);
354
+ // Register the runId so ChatHandler ignores events for this notification run
355
+ const notifyRunId = response.payload?.runId;
356
+ if (notifyRunId) {
357
+ this._chatHandler.registerExternalRun(notifyRunId);
358
+ // Auto-unregister after timeout — these are fire-and-forget notifications
359
+ setTimeout(() => {
360
+ this._chatHandler.unregisterExternalRun(notifyRunId);
361
+ }, this._timeoutMs).unref?.();
362
+ }
363
+ }
364
+ catch {
365
+ // Notification failure is non-fatal — OpenClaw may still pick it up
366
+ }
367
+ };
368
+ // Execute ICP flow
369
+ const result = await this._icpOrchestrator.execute(icpRequest, {
370
+ onProgress,
371
+ notifyAI,
372
+ signal,
373
+ });
374
+ // Guard: if aborted while awaiting ICP, don't send duplicate result
375
+ if (!this._activeInstalls.has(requestId))
376
+ return;
377
+ // Handle result
378
+ switch (result.status) {
379
+ case 'success': {
380
+ const pkg = result.output.package;
381
+ this._send({
382
+ type: 'install_result',
383
+ requestId,
384
+ status: 'success',
385
+ app: {
386
+ manifest: pkg.manifest,
387
+ html: pkg.html ?? '',
388
+ },
389
+ }).catch(() => { });
390
+ this._activeInstalls.delete(requestId);
391
+ break;
392
+ }
393
+ case 'timeout': {
394
+ // ICP timed out — remove ICP tracking, fall back to chat-based
395
+ this._activeInstalls.delete(requestId);
396
+ console.warn(`[InstallHandler] ICP timeout for ${requestId}, falling back to chat`);
397
+ await this._handleViaChatFallback(requestId, payload);
398
+ break;
399
+ }
400
+ case 'failed': {
401
+ this._send({
402
+ type: 'install_result',
403
+ requestId,
404
+ status: 'failed',
405
+ error: result.error,
406
+ code: INSTALL_ERROR_CODES.ICP_FAILED,
407
+ }).catch(() => { });
408
+ this._activeInstalls.delete(requestId);
409
+ break;
410
+ }
411
+ default: {
412
+ // Unknown status — send failure
413
+ this._send({
414
+ type: 'install_result',
415
+ requestId,
416
+ status: 'failed',
417
+ error: `Unexpected ICP result status: ${result.status}`,
418
+ code: INSTALL_ERROR_CODES.ICP_FAILED,
419
+ }).catch(() => { });
420
+ this._activeInstalls.delete(requestId);
421
+ break;
422
+ }
423
+ }
424
+ }
425
+ /**
426
+ * Build an IcpRequest from an InstallRequest payload.
427
+ * Returns null if the payload can't be mapped to an ICP request.
428
+ */
429
+ _buildIcpRequest(action, payload) {
430
+ if ((action === 'install_with_config' || action === 'complete_template') && payload.package) {
431
+ const pkg = payload.package;
432
+ const installAction = resolveInstallAction(pkg);
433
+ if (!installAction.valid)
434
+ return null;
435
+ return { type: 'adapt', pkg, installAction };
436
+ }
437
+ if (action === 'generate_fresh' && payload.package) {
438
+ const pkg = payload.package;
439
+ // Prefer Desktop-provided userRequest; fall back to app name
440
+ const userRequest = payload.userRequest
441
+ ?? (typeof pkg.manifest?.name === 'string'
442
+ ? `Create app: ${pkg.manifest.name}`
443
+ : `Create app: ${pkg.manifest?.name?.en ?? 'New App'}`);
444
+ return {
445
+ type: 'create',
446
+ userRequest,
447
+ manifest: pkg.manifest,
448
+ };
449
+ }
450
+ if (action === 'adapt' && payload.html && payload.manifest) {
451
+ const manifest = payload.manifest;
452
+ const pkg = {
453
+ manifest,
454
+ html: payload.html,
455
+ prompt: { seed: { goal: `Adapt ${manifest.id ?? 'app'}` } },
456
+ };
457
+ const installAction = resolveInstallAction(pkg);
458
+ if (!installAction.valid)
459
+ return null;
460
+ return { type: 'adapt', pkg, installAction };
461
+ }
462
+ return null;
463
+ }
464
+ /**
465
+ * Fall back to the existing chat-based flow.
466
+ * Called when ICP times out or can't handle the request.
467
+ */
468
+ async _handleViaChatFallback(requestId, payload) {
469
+ const action = payload.action ?? 'install';
470
+ // Create active install tracker (same as the normal chat flow)
471
+ const install = {
472
+ requestId,
473
+ action,
474
+ runId: null,
475
+ parser: new StreamingMarkerParser(),
476
+ startedAt: Date.now(),
477
+ timeoutTimer: null,
478
+ lastKnownLength: 0,
479
+ legacy: false,
480
+ lastStatusAt: 0,
481
+ streamMarkers: [],
482
+ hasConfirmedPlan: false,
483
+ icpAbort: null,
484
+ };
485
+ this._activeInstalls.set(requestId, install);
486
+ const promptPayload = this._buildPromptPayload(payload);
487
+ const prompt = buildAppRequestPrompt(promptPayload, this._workspaceSynced);
488
+ await this._sendStatus(requestId, 'sending', 'Falling back to chat-based flow...');
489
+ try {
490
+ const response = await this._wsClient.request('chat.send', {
491
+ sessionKey: this._sessionKey,
492
+ message: prompt,
493
+ idempotencyKey: crypto.randomUUID(),
494
+ }, this._rpcTimeoutMs);
495
+ if (!response.ok) {
496
+ this._completeInstall(requestId, {
497
+ status: 'failed',
498
+ error: response.error?.message || 'chat.send failed',
499
+ code: INSTALL_ERROR_CODES.SEND_FAILED,
500
+ });
501
+ return;
502
+ }
503
+ const runId = response.payload?.runId;
504
+ if (!runId) {
505
+ this._completeInstall(requestId, {
506
+ status: 'failed',
507
+ error: 'No runId returned from chat.send',
508
+ code: INSTALL_ERROR_CODES.SEND_FAILED,
509
+ });
510
+ return;
511
+ }
512
+ install.runId = runId;
513
+ this._runToRequest.set(runId, requestId);
514
+ this._chatHandler.registerExternalRun(runId);
515
+ install.timeoutTimer = setTimeout(() => {
516
+ this._handleTimeout(requestId);
517
+ }, this._timeoutMs);
518
+ if (install.timeoutTimer.unref)
519
+ install.timeoutTimer.unref();
520
+ await this._sendStatus(requestId, 'ai_processing', 'Waiting for AI response...');
521
+ }
522
+ catch (err) {
523
+ this._completeInstall(requestId, {
524
+ status: 'failed',
525
+ error: err.message || 'Unknown error',
526
+ code: INSTALL_ERROR_CODES.SEND_FAILED,
527
+ });
528
+ }
529
+ }
530
+ /**
531
+ * Generate human-readable message for ICP progress phases.
532
+ */
533
+ _icpPhaseMessage(progress) {
534
+ switch (progress.phase) {
535
+ case 'writing_task': return 'Preparing task for AI...';
536
+ case 'waiting_output': return 'Waiting for AI to complete task...';
537
+ case 'validating': return `Validating output (round ${progress.round ?? 1})...`;
538
+ case 'writing_review': return `Quality check failed, requesting revision (round ${progress.round}/${progress.maxRounds})...`;
539
+ case 'waiting_revision': return `Waiting for AI revision (round ${progress.round}/${progress.maxRounds})...`;
540
+ case 'fallback': return 'Applying auto-fix (degraded delivery)...';
541
+ case 'delivering': return 'Quality check passed, delivering...';
542
+ default: return 'Processing...';
543
+ }
544
+ }
249
545
  // ── Private: Event Handling ────────────────────────────────────
250
546
  /**
251
547
  * Handle a streaming chat event for an install run.
@@ -631,3 +927,15 @@ export class InstallHandler {
631
927
  }
632
928
  }
633
929
  }
930
+ /** Actions that should be routed through ICP workspace flow. */
931
+ Object.defineProperty(InstallHandler, "ICP_ACTIONS", {
932
+ enumerable: true,
933
+ configurable: true,
934
+ writable: true,
935
+ value: new Set([
936
+ 'install_with_config',
937
+ 'complete_template',
938
+ 'generate_fresh',
939
+ 'adapt',
940
+ ])
941
+ });
@@ -33,6 +33,8 @@ export interface InstallRequest {
33
33
  };
34
34
  /** Confirmed preview plan (for action='install' following a preview). */
35
35
  confirmedPlan?: InstallPreviewData;
36
+ /** User's original request text (for action='generate_fresh'). */
37
+ userRequest?: string;
36
38
  /** Target app ID (for action='adapt'). */
37
39
  appId?: string;
38
40
  /** App manifest (for action='adapt'). */
@@ -120,7 +122,7 @@ export interface InstallAck {
120
122
  reason?: string;
121
123
  }
122
124
  /** Install processing phases. */
123
- export type InstallPhase = 'sending' | 'ai_processing' | 'parsing' | 'validating' | 'previewing' | 'awaiting_confirm' | 'executing';
125
+ export type InstallPhase = 'sending' | 'ai_processing' | 'parsing' | 'validating' | 'previewing' | 'awaiting_confirm' | 'executing' | 'icp_writing_task' | 'icp_waiting_output' | 'icp_validating' | 'icp_reviewing' | 'icp_fallback' | 'icp_delivering';
124
126
  /**
125
127
  * Progress update during install processing.
126
128
  */
@@ -192,6 +194,10 @@ export declare const INSTALL_ERROR_CODES: {
192
194
  readonly AI_ERROR: "INSTALL_AI_ERROR";
193
195
  /** V2: AI response didn't contain preview data. */
194
196
  readonly NO_PREVIEW_DATA: "INSTALL_NO_PREVIEW_DATA";
197
+ /** V3/ICP: Workspace output timed out, falling back to chat-based. */
198
+ readonly ICP_TIMEOUT: "INSTALL_ICP_TIMEOUT";
199
+ /** V3/ICP: ICP orchestrator failed. */
200
+ readonly ICP_FAILED: "INSTALL_ICP_FAILED";
195
201
  };
196
202
  /** All install-related messages from Desktop to Agent. */
197
203
  export type InstallIncoming = InstallRequest | InstallAbort;
@@ -33,4 +33,8 @@ export const INSTALL_ERROR_CODES = {
33
33
  AI_ERROR: 'INSTALL_AI_ERROR',
34
34
  /** V2: AI response didn't contain preview data. */
35
35
  NO_PREVIEW_DATA: 'INSTALL_NO_PREVIEW_DATA',
36
+ /** V3/ICP: Workspace output timed out, falling back to chat-based. */
37
+ ICP_TIMEOUT: 'INSTALL_ICP_TIMEOUT',
38
+ /** V3/ICP: ICP orchestrator failed. */
39
+ ICP_FAILED: 'INSTALL_ICP_FAILED',
36
40
  };
@@ -10,6 +10,7 @@
10
10
  import type { ServiceManager, ServiceStatus, ServiceInstallOptions } from './platform.js';
11
11
  export declare class LaunchdService implements ServiceManager {
12
12
  install(relayServer: string, options?: ServiceInstallOptions): Promise<void>;
13
+ restart(): Promise<void>;
13
14
  uninstall(): Promise<void>;
14
15
  status(): Promise<ServiceStatus>;
15
16
  }
@@ -105,6 +105,14 @@ ${args.map(a => ` <string>${escapeXml(a)}</string>`).join('\n')}
105
105
  await fs.writeFile(PLIST_PATH, plist, 'utf-8');
106
106
  await bootstrapService();
107
107
  }
108
+ async restart() {
109
+ const st = await this.status();
110
+ if (!st.installed) {
111
+ throw new Error('Service not installed. Run setup first.');
112
+ }
113
+ await bootoutService();
114
+ await bootstrapService();
115
+ }
108
116
  async uninstall() {
109
117
  await bootoutService();
110
118
  try {
@@ -10,6 +10,7 @@ export interface ServiceInstallOptions {
10
10
  export interface ServiceManager {
11
11
  install(relayServer: string, options?: ServiceInstallOptions): Promise<void>;
12
12
  uninstall(): Promise<void>;
13
+ restart(): Promise<void>;
13
14
  status(): Promise<ServiceStatus>;
14
15
  }
15
16
  export interface ServiceStatus {
@@ -11,6 +11,7 @@
11
11
  import type { ServiceManager, ServiceStatus, ServiceInstallOptions } from './platform.js';
12
12
  export declare class SystemdService implements ServiceManager {
13
13
  install(relayServer: string, options?: ServiceInstallOptions): Promise<void>;
14
+ restart(): Promise<void>;
14
15
  uninstall(): Promise<void>;
15
16
  status(): Promise<ServiceStatus>;
16
17
  }
@@ -171,6 +171,13 @@ WantedBy=default.target
171
171
  console.log(` ⚠ loginctl enable-linger failed (may require sudo). Service will start on login.`);
172
172
  }
173
173
  }
174
+ async restart() {
175
+ const userSessionOk = await isUserSessionAvailable();
176
+ if (!userSessionOk) {
177
+ throw new Error('systemd user session not available. Try: sudo loginctl enable-linger $(whoami)');
178
+ }
179
+ await execFileAsync('systemctl', ['--user', 'restart', UNIT_NAME]);
180
+ }
174
181
  async uninstall() {
175
182
  try {
176
183
  await execFileAsync('systemctl', ['--user', 'disable', '--now', UNIT_NAME]);
@@ -7,6 +7,7 @@
7
7
  import type { ServiceManager, ServiceStatus, ServiceInstallOptions } from './platform.js';
8
8
  export declare class WindowsService implements ServiceManager {
9
9
  install(relayServer: string, options?: ServiceInstallOptions): Promise<void>;
10
+ restart(): Promise<void>;
10
11
  uninstall(): Promise<void>;
11
12
  status(): Promise<ServiceStatus>;
12
13
  }
@@ -128,6 +128,13 @@ export class WindowsService {
128
128
  }
129
129
  catch { /* may fail if already running */ }
130
130
  }
131
+ async restart() {
132
+ try {
133
+ await execFileAsync('schtasks', ['/End', '/TN', TASK_NAME]);
134
+ }
135
+ catch { /* not running */ }
136
+ await execFileAsync('schtasks', ['/Run', '/TN', TASK_NAME]);
137
+ }
131
138
  async uninstall() {
132
139
  try {
133
140
  await execFileAsync('schtasks', ['/End', '/TN', TASK_NAME]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echoclaw-relay-agent",
3
- "version": "0.22.4",
3
+ "version": "0.22.6",
4
4
  "description": "EchoClaw Relay Connection — E2E encrypted relay transport, pairing, and session management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,6 +22,8 @@
22
22
  "dev": "tsx src/cli.ts"
23
23
  },
24
24
  "dependencies": {
25
+ "@echoclaw/claw-engine": "workspace:*",
26
+ "@echoclaw/icp-orchestrator": "workspace:*",
25
27
  "echoclaw-crypto": "0.3.1",
26
28
  "ws": "^8.18.0"
27
29
  },