@clawling/clawchat-plugin-openclaw 2026.7.29-3 → 2026.8.2-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/dist/src/api-client.js +3 -0
- package/dist/src/awareness-note-turn.js +26 -0
- package/dist/src/client.js +1 -0
- package/dist/src/liveware-sample.js +272 -41
- package/dist/src/outbound.js +81 -3
- package/dist/src/protocol-types.js +18 -0
- package/dist/src/runtime.js +96 -7
- package/dist/src/tools-schema.js +3 -0
- package/dist/src/tools.js +14 -2
- package/dist/src/ws-alignment.js +18 -1
- package/dist/src/ws-client.js +246 -34
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/skills/clawchat-core/SKILL.md +3 -2
- package/skills/manifest.json +6 -6
- package/src/api-client.ts +4 -0
- package/src/awareness-note-turn.ts +45 -0
- package/src/client.ts +3 -0
- package/src/liveware-sample.ts +287 -41
- package/src/outbound.ts +118 -4
- package/src/protocol-types.ts +19 -0
- package/src/runtime.ts +112 -7
- package/src/storage.ts +8 -2
- package/src/tools-schema.ts +5 -0
- package/src/tools.ts +21 -1
- package/src/ws-alignment.ts +28 -1
- package/src/ws-client.ts +267 -32
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: clawchat-core
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.2.0
|
|
4
4
|
description: Use when a request involves ClawChat profile, friends, user search, moments/dynamics, comments, reactions, avatar, media, memory, output visibility, read-only conversation lookup, sending an image, file, or voice/audio clip into a conversation, or plugin install/update/activation.
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -74,6 +74,7 @@ Tool descriptions are authoritative. These routing hints resolve common ambiguit
|
|
|
74
74
|
| Remove/unfriend contact | `clawchat_remove_friend` with exact `friendUserId`; list friends first when ambiguous |
|
|
75
75
|
| Inspect one conversation or group by exact id | `clawchat_get_conversation` |
|
|
76
76
|
| View/browse moments or dynamics | `clawchat_list_moments` |
|
|
77
|
+
| Read one moment and its visible comments by exact id | `clawchat_get_moment` with exact `momentId`; read-only, use after a `moment.comment.created`/`moment.comment.replied` awareness note to read the new comment before deciding whether to reply |
|
|
77
78
|
| Create a moment/dynamic | `clawchat_create_moment`; upload local images first and pass URLs |
|
|
78
79
|
| Delete a moment/dynamic | `clawchat_delete_moment` with an exact `momentId` |
|
|
79
80
|
| React/unreact to a moment | `clawchat_toggle_moment_reaction` with exact `momentId` and emoji |
|
|
@@ -111,7 +112,7 @@ If the user only asks to edit a local-only identity detail that is not shown on
|
|
|
111
112
|
|
|
112
113
|
For avatar changes, save the returned `avatar_url` back to the identity file after `clawchat_upload_avatar_image` succeeds and before the final response. Do not leave only the local image path in `SOUL.md` or `soul.md` when a hosted ClawChat avatar URL was created.
|
|
113
114
|
|
|
114
|
-
For moments/dynamics, list first when the user refers to "this", "latest", "that post", "the one from earlier", or another ambiguous target. Use exact ids returned by the tools.
|
|
115
|
+
For moments/dynamics, list first when the user refers to "this", "latest", "that post", "the one from earlier", or another ambiguous target. Use exact ids returned by the tools. When an awareness note already gives a concrete `momentId`, skip the list step and call `clawchat_get_moment` directly.
|
|
115
116
|
|
|
116
117
|
For conversations/groups, use only `clawchat_get_conversation` to inspect existing conversation information when the exact conversation id is known.
|
|
117
118
|
|
package/skills/manifest.json
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
"skills": {
|
|
4
4
|
"openclaw": {
|
|
5
5
|
"clawchat-core": {
|
|
6
|
-
"version": "1.
|
|
6
|
+
"version": "1.2.0",
|
|
7
7
|
"path": "openclaw/clawchat-core/SKILL.md",
|
|
8
|
-
"sha256": "
|
|
9
|
-
"bytes":
|
|
8
|
+
"sha256": "a558a23e1ed8c625a3c28a7893d3df1eb651c344f331204c32a42e34486ee1cb",
|
|
9
|
+
"bytes": 9278
|
|
10
10
|
},
|
|
11
11
|
"clawchat-liveware": {
|
|
12
12
|
"version": "1.1.0",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
},
|
|
30
30
|
"hermes": {
|
|
31
31
|
"clawchat-core": {
|
|
32
|
-
"version": "1.
|
|
32
|
+
"version": "1.4.1",
|
|
33
33
|
"path": "hermes/clawchat-core/SKILL.md",
|
|
34
|
-
"sha256": "
|
|
35
|
-
"bytes":
|
|
34
|
+
"sha256": "33488a7a77a1bd950e2d791420370cac27cc9d8717042251d6df5f8b781dc904",
|
|
35
|
+
"bytes": 11992
|
|
36
36
|
},
|
|
37
37
|
"clawchat-liveware": {
|
|
38
38
|
"version": "1.1.0",
|
package/src/api-client.ts
CHANGED
|
@@ -117,6 +117,7 @@ export interface OpenclawClawlingApiClient {
|
|
|
117
117
|
searchUsers(params: { q?: string; limit?: number }): Promise<{ users: UserSearchHit[] }>;
|
|
118
118
|
listMoments(params: { before?: number; limit?: number }): Promise<{ moments: MomentView[] }>;
|
|
119
119
|
createMoment(body: { text?: string; images?: string[] }): Promise<{ moment: MomentView }>;
|
|
120
|
+
getMoment(momentId: number): Promise<{ moment: MomentView }>;
|
|
120
121
|
deleteMoment(momentId: number): Promise<{ ok: boolean }>;
|
|
121
122
|
toggleMomentReaction(params: {
|
|
122
123
|
momentId: number;
|
|
@@ -584,6 +585,9 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
|
|
|
584
585
|
headers: { "content-type": "application/json" },
|
|
585
586
|
});
|
|
586
587
|
},
|
|
588
|
+
async getMoment(momentId): Promise<{ moment: MomentView }> {
|
|
589
|
+
return await call<{ moment: MomentView }>("GET", `/v1/moments/${encodeURIComponent(String(momentId))}`);
|
|
590
|
+
},
|
|
587
591
|
async deleteMoment(momentId): Promise<{ ok: boolean }> {
|
|
588
592
|
return await call<{ ok: boolean }>("DELETE", `/v1/moments/${encodeURIComponent(String(momentId))}`);
|
|
589
593
|
},
|
|
@@ -62,3 +62,48 @@ export function buildAwarenessNoteEnvelope(
|
|
|
62
62
|
},
|
|
63
63
|
} as unknown as Envelope;
|
|
64
64
|
}
|
|
65
|
+
|
|
66
|
+
export interface BuildMomentCommentNoteEnvelopeParams {
|
|
67
|
+
account: ResolvedOpenclawClawlingAccount;
|
|
68
|
+
/**
|
|
69
|
+
* The owner's direct conversation id (cnv_…) recorded at activation.
|
|
70
|
+
*
|
|
71
|
+
* Required and non-null: there is no valid fallback. Addressing the note to
|
|
72
|
+
* `account.ownerUserId` instead makes the agent's in-turn replies unroutable
|
|
73
|
+
* (member-backend answers "invalid conversation id"), so callers must skip
|
|
74
|
+
* the note entirely while no conversation is on record rather than hand a
|
|
75
|
+
* usr_… id down here.
|
|
76
|
+
*/
|
|
77
|
+
ownerConversationId: string;
|
|
78
|
+
momentId: string | number;
|
|
79
|
+
replied: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildMomentCommentNoteEnvelope(
|
|
83
|
+
params: BuildMomentCommentNoteEnvelopeParams,
|
|
84
|
+
): Envelope {
|
|
85
|
+
const { account, ownerConversationId, momentId, replied } = params;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const text = replied
|
|
88
|
+
? `ClawChat: someone replied to your comment on a moment (id ${momentId}). Use the get_moment tool to view it and reply if appropriate.`
|
|
89
|
+
: `ClawChat: someone commented on your moment (id ${momentId}). Use the get_moment tool to view it and reply if appropriate.`;
|
|
90
|
+
return {
|
|
91
|
+
version: "2",
|
|
92
|
+
event: EVENT.MESSAGE_SEND,
|
|
93
|
+
trace_id: `clawchat-plugin-openclaw-moment-comment-${now}`,
|
|
94
|
+
emitted_at: now,
|
|
95
|
+
chat_id: ownerConversationId,
|
|
96
|
+
chat_type: "direct",
|
|
97
|
+
to: { id: account.userId, type: "direct" },
|
|
98
|
+
sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
|
|
99
|
+
payload: {
|
|
100
|
+
message_id: `clawchat-plugin-openclaw-moment-comment-${now}`,
|
|
101
|
+
message_mode: "normal",
|
|
102
|
+
message: {
|
|
103
|
+
body: { fragments: [{ kind: "text", text }] },
|
|
104
|
+
context: { mentions: [], reply: null },
|
|
105
|
+
streaming: { status: "static", sequence: 0, mutation_policy: "sealed", started_at: null, completed_at: null },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
} as unknown as Envelope;
|
|
109
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface CreateClientOverrides {
|
|
|
16
16
|
* instead of minting a new one and forcing a full inbox replay.
|
|
17
17
|
*/
|
|
18
18
|
deviceIdOverride?: string;
|
|
19
|
+
/** Injectable clock, forwarded to the WS client. Tests only. */
|
|
20
|
+
now?: () => number;
|
|
19
21
|
wsLifecycle?: {
|
|
20
22
|
onConnectFrameSent?: (env: {
|
|
21
23
|
trace_id?: unknown;
|
|
@@ -59,6 +61,7 @@ export function createOpenclawClawlingClient(
|
|
|
59
61
|
timeout: account.ack.timeout,
|
|
60
62
|
autoResendOnTimeout: account.ack.autoResendOnTimeout,
|
|
61
63
|
},
|
|
64
|
+
...(overrides.now ? { now: overrides.now } : {}),
|
|
62
65
|
});
|
|
63
66
|
if (overrides.wsLifecycle?.onConnectFrameSent) {
|
|
64
67
|
const sendRawEnvelope = client.sendRawEnvelope.bind(client);
|
package/src/liveware-sample.ts
CHANGED
|
@@ -88,28 +88,76 @@ async function fetchText(fetchFn: FetchLike, url: string): Promise<string> {
|
|
|
88
88
|
return text;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* True when `appDir` already holds what the manifest pins: every listed file
|
|
93
|
+
* present, program files byte-identical to their manifest sha256, and nothing
|
|
94
|
+
* beyond those files (so a file dropped by a newer manifest still forces a clean
|
|
95
|
+
* reinstall). USER_DATA_FILES are checked for presence only, never for content —
|
|
96
|
+
* `state.json` ships in the manifest *and* is owned by the agent afterwards, so
|
|
97
|
+
* hashing it would make the short-circuit miss on exactly the installs that are
|
|
98
|
+
* actually in use. Cheap (~20 KB of local reads) next to the per-file GETs it
|
|
99
|
+
* saves, and fail-safe: any read error answers "no".
|
|
100
|
+
*/
|
|
101
|
+
async function localInstallMatches(
|
|
102
|
+
appDir: string,
|
|
103
|
+
entry: LivewareSampleManifestEntry,
|
|
104
|
+
): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
const expected = new Set(entry.files.map((f) => path.basename(f.path)));
|
|
107
|
+
for (const name of await fs.promises.readdir(appDir)) {
|
|
108
|
+
if (!expected.has(name) && !USER_DATA_FILES.includes(name)) return false;
|
|
109
|
+
}
|
|
110
|
+
for (const file of entry.files) {
|
|
111
|
+
const name = path.basename(file.path);
|
|
112
|
+
const target = path.join(appDir, name);
|
|
113
|
+
if (USER_DATA_FILES.includes(name)) {
|
|
114
|
+
await fs.promises.access(target); // agent-owned: presence is the whole check
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const buf = await fs.promises.readFile(target);
|
|
118
|
+
if (crypto.createHash("sha256").update(buf).digest("hex") !== file.sha256) return false;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return false; // missing / unreadable — treat as "not installed"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
91
126
|
/**
|
|
92
127
|
* Download + verify + atomically install the sample under `<sampleRoot>/app`.
|
|
93
128
|
* Preserves USER_DATA_FILES from an existing install. Throws on any failure;
|
|
94
129
|
* never leaves a partially-written `app/` dir behind.
|
|
130
|
+
*
|
|
131
|
+
* Re-install short-circuit: when the installed bytes already match every hash
|
|
132
|
+
* the manifest pins, nothing is fetched or rewritten (`reused: true`). Every
|
|
133
|
+
* boot used to re-fetch the whole bundle from GitHub raw even when the identical
|
|
134
|
+
* files were already on disk — a handful of serial round-trips on the critical
|
|
135
|
+
* path, and an unreachable raw.githubusercontent then failed a sample that was
|
|
136
|
+
* already fully installed. Only the manifest itself is still fetched every time,
|
|
137
|
+
* because that is what tells us which hashes to compare against.
|
|
95
138
|
*/
|
|
96
139
|
export async function downloadLivewareSample(opts: {
|
|
97
140
|
fetchFn: FetchLike;
|
|
98
141
|
sampleRoot: string;
|
|
99
142
|
ref?: string;
|
|
100
143
|
base?: string;
|
|
101
|
-
}): Promise<{ version: string; appDir: string }> {
|
|
144
|
+
}): Promise<{ version: string; appDir: string; reused?: boolean }> {
|
|
102
145
|
const ref = opts.ref ?? DEFAULT_SKILLS_REF;
|
|
103
146
|
const entry = parseLivewaresManifest(
|
|
104
147
|
await fetchText(opts.fetchFn, livewaresManifestUrl(ref, opts.base)),
|
|
105
148
|
);
|
|
106
149
|
|
|
107
150
|
const appDir = path.join(opts.sampleRoot, "app");
|
|
151
|
+
if (await localInstallMatches(appDir, entry)) {
|
|
152
|
+
return { version: entry.version, appDir, reused: true };
|
|
153
|
+
}
|
|
108
154
|
const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
|
|
109
155
|
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
110
156
|
await fs.promises.mkdir(tmpDir, { recursive: true });
|
|
111
157
|
try {
|
|
112
|
-
|
|
158
|
+
// Fetched concurrently: the files are independent and each is a separate
|
|
159
|
+
// round-trip to the same host, so serializing them only added latency.
|
|
160
|
+
await Promise.all(entry.files.map(async (file) => {
|
|
113
161
|
const text = await fetchText(opts.fetchFn, livewareFileUrl(file.path, ref, opts.base));
|
|
114
162
|
const buf = Buffer.from(text, "utf8");
|
|
115
163
|
const actual = crypto.createHash("sha256").update(buf).digest("hex");
|
|
@@ -117,7 +165,7 @@ export async function downloadLivewareSample(opts: {
|
|
|
117
165
|
throw new Error(`sha256 mismatch for ${file.path}: expected ${file.sha256} got ${actual}`);
|
|
118
166
|
}
|
|
119
167
|
await fs.promises.writeFile(path.join(tmpDir, path.basename(file.path)), buf);
|
|
120
|
-
}
|
|
168
|
+
}));
|
|
121
169
|
// Preserve agent/user-owned data files from a previous install.
|
|
122
170
|
for (const name of USER_DATA_FILES) {
|
|
123
171
|
const prev = path.join(appDir, name);
|
|
@@ -351,6 +399,96 @@ function findIdDeep(value: unknown): string | null {
|
|
|
351
399
|
return null;
|
|
352
400
|
}
|
|
353
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Find the id of an existing liveware app named `name` in `liveware app list`
|
|
404
|
+
* output. Deliberately conservative: a shape this cannot read must degrade to
|
|
405
|
+
* "not found" — the caller then creates an app, i.e. the previous behaviour —
|
|
406
|
+
* and must never return a WRONG id, so the text branch only accepts a line that
|
|
407
|
+
* contains the exact app name plus one id-looking token outside it, and an
|
|
408
|
+
* id-looking token must carry a digit/`-`/`_` so a status word ("active",
|
|
409
|
+
* "running") cannot pass for an id. JSON output (any wrapper key) is the
|
|
410
|
+
* reliable path; the aligned-table branch is calibrated on `app create`'s table,
|
|
411
|
+
* the only text shape verified against the real CLI.
|
|
412
|
+
*/
|
|
413
|
+
export function findAppIdByName(stdout: string, name: string): string | null {
|
|
414
|
+
const text = (stdout ?? "").trim();
|
|
415
|
+
const wanted = (name ?? "").trim();
|
|
416
|
+
if (!text || !wanted) return null;
|
|
417
|
+
try {
|
|
418
|
+
const found = findNamedIdDeep(JSON.parse(text), wanted);
|
|
419
|
+
if (found) return found;
|
|
420
|
+
} catch { /* not JSON */ }
|
|
421
|
+
for (const line of text.split("\n")) {
|
|
422
|
+
const at = line.indexOf(wanted);
|
|
423
|
+
if (at < 0) continue;
|
|
424
|
+
const outsideName = `${line.slice(0, at)} ${line.slice(at + wanted.length)}`;
|
|
425
|
+
for (const token of outsideName.trim().split(/\s+/)) {
|
|
426
|
+
if (isAppIdToken(token)) return token;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isAppIdToken(token: string): boolean {
|
|
433
|
+
return /^[A-Za-z0-9][A-Za-z0-9_-]{5,}$/.test(token) && /[0-9_-]/.test(token);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function findNamedIdDeep(value: unknown, name: string): string | null {
|
|
437
|
+
if (Array.isArray(value)) {
|
|
438
|
+
for (const item of value) {
|
|
439
|
+
const found = findNamedIdDeep(item, name);
|
|
440
|
+
if (found) return found;
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
if (!value || typeof value !== "object") return null;
|
|
445
|
+
const obj = value as Record<string, unknown>;
|
|
446
|
+
const named = ["name", "app_name", "appName", "title"].some(
|
|
447
|
+
(key) => typeof obj[key] === "string" && (obj[key] as string).trim() === name,
|
|
448
|
+
);
|
|
449
|
+
if (named) {
|
|
450
|
+
const id = findIdDeep(obj);
|
|
451
|
+
if (id) return id;
|
|
452
|
+
}
|
|
453
|
+
for (const nested of Object.values(obj)) {
|
|
454
|
+
const found = findNamedIdDeep(nested, name);
|
|
455
|
+
if (found) return found;
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* `liveware app list` → the id of an existing app with this name, or null.
|
|
462
|
+
* Best-effort by contract: an exec failure or unreadable output resolves to null
|
|
463
|
+
* so the caller falls back to `app create` instead of failing the whole flow.
|
|
464
|
+
*/
|
|
465
|
+
export async function livewareAppFindByName(opts: {
|
|
466
|
+
livewarePath: string;
|
|
467
|
+
name: string;
|
|
468
|
+
execFileFn?: ExecFileLike;
|
|
469
|
+
log?: LivewareLogger;
|
|
470
|
+
}): Promise<string | null> {
|
|
471
|
+
const execFileFn = opts.execFileFn ?? nodeExecFile;
|
|
472
|
+
const stdout = await new Promise<string | null>((resolve) => {
|
|
473
|
+
execFileFn(
|
|
474
|
+
opts.livewarePath,
|
|
475
|
+
["app", "list"],
|
|
476
|
+
{ timeout: CLI_TIMEOUT_MS },
|
|
477
|
+
(err, out, errOut) => {
|
|
478
|
+
if (err) {
|
|
479
|
+
opts.log?.debug?.(
|
|
480
|
+
`liveware-sample: app list failed; falling back to app create: ${String(errOut || out || err)}`,
|
|
481
|
+
);
|
|
482
|
+
resolve(null);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
resolve(String(out ?? ""));
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
});
|
|
489
|
+
return stdout == null ? null : findAppIdByName(stdout, opts.name);
|
|
490
|
+
}
|
|
491
|
+
|
|
354
492
|
export async function livewareAppCreate(opts: {
|
|
355
493
|
livewarePath: string;
|
|
356
494
|
name: string;
|
|
@@ -379,7 +517,7 @@ export type LivewareSampleStoreLike = {
|
|
|
379
517
|
updateLivewareSampleStatus(input: {
|
|
380
518
|
platform: string;
|
|
381
519
|
accountId: string;
|
|
382
|
-
status: "active" | "failed" | "disabled";
|
|
520
|
+
status: "pending" | "active" | "failed" | "disabled";
|
|
383
521
|
lastError?: string | null;
|
|
384
522
|
}): void;
|
|
385
523
|
markLivewareSampleIntroSent(input: { platform: string; accountId: string }): void;
|
|
@@ -428,10 +566,15 @@ const INTRO_MAX_TRIES = 20;
|
|
|
428
566
|
const START_RETRY_DELAYS_MS = [30_000, 60_000, 120_000, 300_000];
|
|
429
567
|
|
|
430
568
|
export class LivewareSampleSupervisor {
|
|
431
|
-
|
|
569
|
+
/** Not readonly: adoptDeps() re-points a live supervisor at a re-entered
|
|
570
|
+
* gateway closure (fresh token / REST client / owner-notify path). */
|
|
571
|
+
private deps: LivewareSampleSupervisorDeps;
|
|
432
572
|
private serverChild: ChildProcess | null = null;
|
|
433
573
|
private tunnelChild: ChildProcess | null = null;
|
|
434
574
|
private stopped = false;
|
|
575
|
+
/** True while a startAttempt chain (bootstrap/relaunch) is running; read by
|
|
576
|
+
* isIdle() so adoptDeps never starts a second concurrent flow. */
|
|
577
|
+
private launchInFlight = false;
|
|
435
578
|
private restartTimes: number[] = [];
|
|
436
579
|
private timers = new Set<ReturnType<typeof setTimeout>>();
|
|
437
580
|
/** Bumped on every (re)launch and on killChildren: a child's exit handler
|
|
@@ -449,8 +592,40 @@ export class LivewareSampleSupervisor {
|
|
|
449
592
|
await this.startAttempt(0);
|
|
450
593
|
}
|
|
451
594
|
|
|
595
|
+
/** Re-point a live supervisor at a freshly re-entered gateway closure, and —
|
|
596
|
+
* only when nothing is in flight — kick a new attempt.
|
|
597
|
+
*
|
|
598
|
+
* `startOpenclawClawlingGateway` re-enters itself on token refresh / transport
|
|
599
|
+
* backoff, so runtime.ts keeps ONE supervisor per (abortSignal, account) and
|
|
600
|
+
* hands the new closure here instead of constructing a second supervisor: two
|
|
601
|
+
* supervisors raced the bootstrap "account has zero apps" gate and registered
|
|
602
|
+
* two liveware apps for the same account, one of which no row then tracked.
|
|
603
|
+
* The re-entered closure owns the fresh token, REST client and owner-notify
|
|
604
|
+
* path; children, timers and the crash-restart budget keep running untouched.
|
|
605
|
+
* A flow already mid-await finishes on the deps it captured. */
|
|
606
|
+
adoptDeps(deps: LivewareSampleSupervisorDeps): void {
|
|
607
|
+
this.deps = deps;
|
|
608
|
+
// A re-entry used to re-run the whole flow through the brand-new supervisor,
|
|
609
|
+
// which is what healed an attempt that had skipped (no token yet) or burnt
|
|
610
|
+
// through its retry ladder. Keep that healing, but only when there is
|
|
611
|
+
// genuinely nothing live: no in-flight flow, no pending retry/crash-restart
|
|
612
|
+
// timer, no children — a second concurrent flow is exactly the duplicate
|
|
613
|
+
// registration the single-supervisor registry exists to prevent.
|
|
614
|
+
if (this.isIdle()) void this.startAttempt(0);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Nothing running and nothing scheduled: safe to (re)start a flow. */
|
|
618
|
+
private isIdle(): boolean {
|
|
619
|
+
return !this.stopped
|
|
620
|
+
&& !this.launchInFlight
|
|
621
|
+
&& this.timers.size === 0
|
|
622
|
+
&& this.serverChild === null
|
|
623
|
+
&& this.tunnelChild === null;
|
|
624
|
+
}
|
|
625
|
+
|
|
452
626
|
private async startAttempt(attempt: number): Promise<void> {
|
|
453
627
|
const { deps } = this;
|
|
628
|
+
this.launchInFlight = true;
|
|
454
629
|
try {
|
|
455
630
|
if (!deps.enabled) return;
|
|
456
631
|
// ensureLivewareCli downloads the CLI asynchronously on first boot;
|
|
@@ -480,6 +655,8 @@ export class LivewareSampleSupervisor {
|
|
|
480
655
|
`clawchat-plugin-openclaw liveware-sample start failed (attempt ${attempt + 1}, retrying in ${delayMs / 1000}s): ${String(err)}`,
|
|
481
656
|
);
|
|
482
657
|
this.schedule(() => { void this.startAttempt(attempt + 1); }, delayMs);
|
|
658
|
+
} finally {
|
|
659
|
+
this.launchInFlight = false;
|
|
483
660
|
}
|
|
484
661
|
}
|
|
485
662
|
|
|
@@ -518,17 +695,16 @@ export class LivewareSampleSupervisor {
|
|
|
518
695
|
private async bootstrap(): Promise<void> {
|
|
519
696
|
const { deps } = this;
|
|
520
697
|
if (this.stopped) return;
|
|
698
|
+
// Retryable-vs-opt-out: a missing CLI, an absent token and a flaky listApps
|
|
699
|
+
// are all transient, but they used to `return`, which left the sample dormant
|
|
700
|
+
// for the whole process lifetime (nothing re-enters bootstrap on its own).
|
|
701
|
+
// Throw instead so startAttempt's START_RETRY_DELAYS_MS ladder gets its turn;
|
|
702
|
+
// only a genuine opt-out (disabled row, owner already has apps) returns.
|
|
521
703
|
const livewarePath = deps.resolveLivewarePath();
|
|
522
|
-
if (!livewarePath)
|
|
704
|
+
if (!livewarePath) throw new Error("liveware CLI not ready");
|
|
523
705
|
const token = deps.resolveToken();
|
|
524
|
-
if (!token)
|
|
525
|
-
|
|
526
|
-
try {
|
|
527
|
-
apps = await deps.listApps();
|
|
528
|
-
} catch (err) {
|
|
529
|
-
deps.log?.debug?.(`liveware-sample: listApps failed; skip bootstrap: ${String(err)}`);
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
706
|
+
if (!token) throw new Error("no ClawChat token available yet");
|
|
707
|
+
const apps = await deps.listApps();
|
|
532
708
|
if (this.bailIfStopped()) return;
|
|
533
709
|
if (apps.apps.length > 0) {
|
|
534
710
|
deps.log?.debug?.("liveware-sample: user already has liveware apps; skip");
|
|
@@ -546,9 +722,30 @@ export class LivewareSampleSupervisor {
|
|
|
546
722
|
this.serverChild = server.child;
|
|
547
723
|
if (this.bailIfStopped()) return;
|
|
548
724
|
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
|
|
549
|
-
|
|
725
|
+
// Reuse an app we already own before minting another one. `app create` is not
|
|
726
|
+
// idempotent and nothing here ever reconciled against the liveware side, so
|
|
727
|
+
// every bootstrap that died past this point (no row was written until the very
|
|
728
|
+
// end, see below) left one more orphan app behind and ate into the account's
|
|
729
|
+
// app quota. Best-effort: an unreadable list falls through to `app create`.
|
|
730
|
+
const existingAppId = await livewareAppFindByName({
|
|
731
|
+
livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
|
|
732
|
+
});
|
|
733
|
+
if (this.bailIfStopped()) return;
|
|
734
|
+
if (existingAppId) {
|
|
735
|
+
deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
|
|
736
|
+
}
|
|
737
|
+
const appId = existingAppId ?? await livewareAppCreate({
|
|
550
738
|
livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
|
|
551
739
|
});
|
|
740
|
+
// Persist the app id BEFORE the steps that can still fail. Without this a
|
|
741
|
+
// failure anywhere below left no row at all, so the next boot ran bootstrap
|
|
742
|
+
// again and created yet another app; a "pending" row makes the next attempt
|
|
743
|
+
// resume through relaunch() with the same app id instead.
|
|
744
|
+
deps.store.upsertLivewareSample({
|
|
745
|
+
platform: deps.platform, accountId: deps.accountId,
|
|
746
|
+
appId, appName: LIVEWARE_SAMPLE_APP_NAME, port: server.port,
|
|
747
|
+
publicUrl: null, sampleVersion: version, status: "pending",
|
|
748
|
+
});
|
|
552
749
|
const publicUrl = await tunnelBind({
|
|
553
750
|
livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
|
|
554
751
|
});
|
|
@@ -570,20 +767,22 @@ export class LivewareSampleSupervisor {
|
|
|
570
767
|
private async relaunch(row: LivewareSampleRow): Promise<void> {
|
|
571
768
|
const { deps } = this;
|
|
572
769
|
if (this.stopped) return;
|
|
770
|
+
// Both of these used to `return` and strand the sample until the next process
|
|
771
|
+
// start; they are transient, so throw and let the retry ladder run (see
|
|
772
|
+
// bootstrap). Only a successful list that no longer contains the app is an
|
|
773
|
+
// opt-out.
|
|
573
774
|
const livewarePath = deps.resolveLivewarePath();
|
|
574
|
-
if (!livewarePath)
|
|
775
|
+
if (!livewarePath) throw new Error("liveware CLI not ready");
|
|
776
|
+
const apps = await deps.listApps();
|
|
575
777
|
// The user deleting the app tile is an explicit opt-out — never reinstall.
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
}
|
|
585
|
-
} catch (err) {
|
|
586
|
-
deps.log?.debug?.(`liveware-sample: listApps failed; skip relaunch: ${String(err)}`);
|
|
778
|
+
// Exception: a "pending" row was persisted mid-bootstrap, before the app was
|
|
779
|
+
// ever registered with ClawChat, so its absence from this list means "not
|
|
780
|
+
// registered yet", not "deleted" — disabling it there would strand the app id.
|
|
781
|
+
if (row.status !== "pending" && !apps.apps.some((a) => a.app_id === row.app_id)) {
|
|
782
|
+
deps.store.updateLivewareSampleStatus({
|
|
783
|
+
platform: deps.platform, accountId: deps.accountId, status: "disabled",
|
|
784
|
+
lastError: "app removed by user",
|
|
785
|
+
});
|
|
587
786
|
return;
|
|
588
787
|
}
|
|
589
788
|
if (this.bailIfStopped()) return;
|
|
@@ -607,21 +806,59 @@ export class LivewareSampleSupervisor {
|
|
|
607
806
|
});
|
|
608
807
|
this.serverChild = server.child;
|
|
609
808
|
if (this.bailIfStopped()) return;
|
|
809
|
+
// Re-login before touching the CLI. Its credentials live in $HOME/.clawling,
|
|
810
|
+
// a different volume from the stateDir holding the row that sent us down this
|
|
811
|
+
// path: a container that persists stateDir but not $HOME comes back with a row
|
|
812
|
+
// AND a logged-out CLI, so every tunnelBind/agent below fails auth until the
|
|
813
|
+
// retry ladder gives up — the sample then never returns for the whole process
|
|
814
|
+
// lifetime. `login` is idempotent; best-effort, so a stale token still falls
|
|
815
|
+
// through to whatever credentials the CLI already has.
|
|
816
|
+
const token = deps.resolveToken();
|
|
817
|
+
if (token) {
|
|
818
|
+
try {
|
|
819
|
+
await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
|
|
820
|
+
} catch (err) {
|
|
821
|
+
deps.log?.warn?.(
|
|
822
|
+
`liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
if (this.bailIfStopped()) return;
|
|
826
|
+
} else {
|
|
827
|
+
deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
|
|
828
|
+
}
|
|
829
|
+
// Kept after the re-login above so the lookup runs against a CLI that is
|
|
830
|
+
// actually authenticated: a pending row's app id came straight from
|
|
831
|
+
// `app create`'s parser and was never confirmed by the liveware side, so
|
|
832
|
+
// prefer a by-name lookup — a mis-parsed id then self-heals instead of
|
|
833
|
+
// failing every tunnel bind for the rest of this row's life.
|
|
834
|
+
let appId = row.app_id;
|
|
835
|
+
if (row.status === "pending") {
|
|
836
|
+
const found = await livewareAppFindByName({
|
|
837
|
+
livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
|
|
838
|
+
});
|
|
839
|
+
if (found && found !== appId) {
|
|
840
|
+
deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
|
|
841
|
+
appId = found;
|
|
842
|
+
}
|
|
843
|
+
if (this.bailIfStopped()) return;
|
|
844
|
+
}
|
|
610
845
|
const publicUrl = await tunnelBind({
|
|
611
|
-
livewarePath, appId
|
|
846
|
+
livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
|
|
612
847
|
});
|
|
613
848
|
if (this.bailIfStopped()) return;
|
|
614
849
|
const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
|
|
615
850
|
this.tunnelChild = agent.child;
|
|
616
851
|
if (this.bailIfStopped()) return;
|
|
617
|
-
|
|
852
|
+
// A pending row has never been registered with ClawChat (public_url is null),
|
|
853
|
+
// so this condition also covers "register it for the first time".
|
|
854
|
+
if (publicUrl !== row.public_url || appId !== row.app_id) {
|
|
618
855
|
await deps.registerApp({
|
|
619
|
-
name: row.app_name, appId
|
|
856
|
+
name: row.app_name, appId, url: publicUrl,
|
|
620
857
|
});
|
|
621
858
|
}
|
|
622
859
|
deps.store.upsertLivewareSample({
|
|
623
860
|
platform: deps.platform, accountId: deps.accountId,
|
|
624
|
-
appId
|
|
861
|
+
appId, appName: row.app_name, port: server.port,
|
|
625
862
|
publicUrl, sampleVersion: version, status: "active",
|
|
626
863
|
});
|
|
627
864
|
this.watchChildren();
|
|
@@ -652,16 +889,25 @@ export class LivewareSampleSupervisor {
|
|
|
652
889
|
platform: this.deps.platform, accountId: this.deps.accountId,
|
|
653
890
|
});
|
|
654
891
|
if (!row || row.status === "disabled") return;
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
892
|
+
// This relaunch does NOT go through startAttempt, so it must do that
|
|
893
|
+
// method's launchInFlight bookkeeping itself: the timer has already been
|
|
894
|
+
// dropped from `timers` and children are only assigned a few awaits in, so
|
|
895
|
+
// without this isIdle() reports idle for the whole early part of a
|
|
896
|
+
// crash-restart and a concurrent adoptDeps kick would run a SECOND launch
|
|
897
|
+
// flow — the duplicate registration the single-supervisor rule prevents.
|
|
898
|
+
this.launchInFlight = true;
|
|
899
|
+
void this.relaunch(row)
|
|
900
|
+
.catch((err) => {
|
|
901
|
+
// A partially-completed relaunch may have spawned children before
|
|
902
|
+
// failing — never leave them running unwatched.
|
|
903
|
+
this.killChildren();
|
|
904
|
+
this.deps.log?.warn?.(`liveware-sample: relaunch failed: ${String(err)}`);
|
|
905
|
+
this.deps.store.updateLivewareSampleStatus({
|
|
906
|
+
platform: this.deps.platform, accountId: this.deps.accountId,
|
|
907
|
+
status: "failed", lastError: String(err),
|
|
908
|
+
});
|
|
909
|
+
})
|
|
910
|
+
.finally(() => { this.launchInFlight = false; });
|
|
665
911
|
}, delay);
|
|
666
912
|
};
|
|
667
913
|
for (const child of [this.serverChild, this.tunnelChild]) {
|