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.
- package/dist/RelayAgent.js +10 -1
- package/dist/RelayClient.js +8 -0
- package/dist/cli.js +32 -0
- package/dist/install/InstallHandler.d.ts +23 -0
- package/dist/install/InstallHandler.js +310 -2
- package/dist/install/types.d.ts +7 -1
- package/dist/install/types.js +4 -0
- package/dist/service/launchd.d.ts +1 -0
- package/dist/service/launchd.js +8 -0
- package/dist/service/platform.d.ts +1 -0
- package/dist/service/systemd.d.ts +1 -0
- package/dist/service/systemd.js +7 -0
- package/dist/service/windows.d.ts +1 -0
- package/dist/service/windows.js +7 -0
- package/package.json +3 -1
package/dist/RelayAgent.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/RelayClient.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
+
});
|
package/dist/install/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/install/types.js
CHANGED
|
@@ -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
|
}
|
package/dist/service/launchd.js
CHANGED
|
@@ -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
|
}
|
package/dist/service/systemd.js
CHANGED
|
@@ -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
|
}
|
package/dist/service/windows.js
CHANGED
|
@@ -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.
|
|
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
|
},
|