@clawling/clawchat-plugin-openclaw 2026.7.29-3 → 2026.8.4-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.
@@ -361,6 +361,9 @@ export function createOpenclawClawlingApiClient(opts) {
361
361
  headers: { "content-type": "application/json" },
362
362
  });
363
363
  },
364
+ async getMoment(momentId) {
365
+ return await call("GET", `/v1/moments/${encodeURIComponent(String(momentId))}`);
366
+ },
364
367
  async deleteMoment(momentId) {
365
368
  return await call("DELETE", `/v1/moments/${encodeURIComponent(String(momentId))}`);
366
369
  },
@@ -44,3 +44,29 @@ export function buildAwarenessNoteEnvelope(params) {
44
44
  },
45
45
  };
46
46
  }
47
+ export function buildMomentCommentNoteEnvelope(params) {
48
+ const { account, ownerConversationId, momentId, replied } = params;
49
+ const now = Date.now();
50
+ const text = replied
51
+ ? `ClawChat: someone replied to your comment on a moment (id ${momentId}). Use the get_moment tool to view it and reply if appropriate.`
52
+ : `ClawChat: someone commented on your moment (id ${momentId}). Use the get_moment tool to view it and reply if appropriate.`;
53
+ return {
54
+ version: "2",
55
+ event: EVENT.MESSAGE_SEND,
56
+ trace_id: `clawchat-plugin-openclaw-moment-comment-${now}`,
57
+ emitted_at: now,
58
+ chat_id: ownerConversationId,
59
+ chat_type: "direct",
60
+ to: { id: account.userId, type: "direct" },
61
+ sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
62
+ payload: {
63
+ message_id: `clawchat-plugin-openclaw-moment-comment-${now}`,
64
+ message_mode: "normal",
65
+ message: {
66
+ body: { fragments: [{ kind: "text", text }] },
67
+ context: { mentions: [], reply: null },
68
+ streaming: { status: "static", sequence: 0, mutation_policy: "sealed", started_at: null, completed_at: null },
69
+ },
70
+ },
71
+ };
72
+ }
@@ -32,6 +32,7 @@ export function createOpenclawClawlingClient(account, overrides = {}) {
32
32
  timeout: account.ack.timeout,
33
33
  autoResendOnTimeout: account.ack.autoResendOnTimeout,
34
34
  },
35
+ ...(overrides.now ? { now: overrides.now } : {}),
35
36
  });
36
37
  if (overrides.wsLifecycle?.onConnectFrameSent) {
37
38
  const sendRawEnvelope = client.sendRawEnvelope.bind(client);
@@ -15,6 +15,35 @@ export const LIVEWARE_SAMPLE_APP_NAME = "Liveware Sample";
15
15
  export const MAX_SAMPLE_FILE_BYTES = 512 * 1024;
16
16
  /** Files the agent owns at runtime; preserved across sample upgrades. */
17
17
  const USER_DATA_FILES = ["state.json", "events.jsonl"];
18
+ /**
19
+ * Windows refuses to unlink or rename a directory while ANY file inside it is
20
+ * still open, and reports it as a sharing violation (EPERM/EBUSY/EACCES) or a
21
+ * non-empty directory. A still-draining sample server, an antivirus scan or the
22
+ * search indexer is enough to trigger it, and all of those clear within
23
+ * milliseconds — so a short bounded retry turns a hard install failure into a
24
+ * brief pause. On POSIX these codes essentially never appear here and the first
25
+ * attempt always wins, so the retry costs nothing.
26
+ */
27
+ const FS_SWAP_RETRY_DELAYS_MS = [50, 150, 400];
28
+ const TRANSIENT_FS_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+ async function withFsSwapRetry(op) {
31
+ let lastError;
32
+ for (let attempt = 0; attempt <= FS_SWAP_RETRY_DELAYS_MS.length; attempt += 1) {
33
+ try {
34
+ return await op();
35
+ }
36
+ catch (err) {
37
+ lastError = err;
38
+ const code = err.code ?? "";
39
+ const delay = FS_SWAP_RETRY_DELAYS_MS[attempt];
40
+ if (!TRANSIENT_FS_CODES.has(code) || delay === undefined)
41
+ break;
42
+ await sleep(delay);
43
+ }
44
+ }
45
+ throw lastError;
46
+ }
18
47
  function livewaresBase(base, ref) {
19
48
  return `${(base ?? OFFICIAL_SKILLS_BASE).replace(/\/+$/, "")}/${ref}/livewares`;
20
49
  }
@@ -62,20 +91,75 @@ async function fetchText(fetchFn, url) {
62
91
  }
63
92
  return text;
64
93
  }
94
+ /**
95
+ * True when `appDir` already holds what the manifest pins: every listed file
96
+ * present, program files byte-identical to their manifest sha256, and nothing
97
+ * beyond those files (so a file dropped by a newer manifest still forces a clean
98
+ * reinstall). USER_DATA_FILES are checked for presence only, never for content —
99
+ * `state.json` ships in the manifest *and* is owned by the agent afterwards, so
100
+ * hashing it would make the short-circuit miss on exactly the installs that are
101
+ * actually in use. Cheap (~20 KB of local reads) next to the per-file GETs it
102
+ * saves, and fail-safe: any read error answers "no".
103
+ */
104
+ async function localInstallMatches(appDir, entry) {
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))
109
+ return false;
110
+ }
111
+ for (const file of entry.files) {
112
+ const name = path.basename(file.path);
113
+ const target = path.join(appDir, name);
114
+ if (USER_DATA_FILES.includes(name)) {
115
+ await fs.promises.access(target); // agent-owned: presence is the whole check
116
+ continue;
117
+ }
118
+ const buf = await fs.promises.readFile(target);
119
+ if (crypto.createHash("sha256").update(buf).digest("hex") !== file.sha256)
120
+ return false;
121
+ }
122
+ return true;
123
+ }
124
+ catch {
125
+ return false; // missing / unreadable — treat as "not installed"
126
+ }
127
+ }
65
128
  /**
66
129
  * Download + verify + atomically install the sample under `<sampleRoot>/app`.
67
130
  * Preserves USER_DATA_FILES from an existing install. Throws on any failure;
68
131
  * never leaves a partially-written `app/` dir behind.
132
+ *
133
+ * Re-install short-circuit: when the installed bytes already match every hash
134
+ * the manifest pins, nothing is fetched or rewritten (`reused: true`). Every
135
+ * boot used to re-fetch the whole bundle from GitHub raw even when the identical
136
+ * files were already on disk — a handful of serial round-trips on the critical
137
+ * path, and an unreachable raw.githubusercontent then failed a sample that was
138
+ * already fully installed. Only the manifest itself is still fetched every time,
139
+ * because that is what tells us which hashes to compare against.
69
140
  */
70
141
  export async function downloadLivewareSample(opts) {
71
142
  const ref = opts.ref ?? DEFAULT_SKILLS_REF;
72
143
  const entry = parseLivewaresManifest(await fetchText(opts.fetchFn, livewaresManifestUrl(ref, opts.base)));
73
144
  const appDir = path.join(opts.sampleRoot, "app");
145
+ if (await localInstallMatches(appDir, entry)) {
146
+ return { version: entry.version, appDir, reused: true };
147
+ }
148
+ // Sweep app.old-* left behind when a previous swap could not delete the old
149
+ // tree (a handle was still open then, but is not now). Best effort: a dir we
150
+ // still cannot remove is simply retried next run.
151
+ for (const name of await fs.promises.readdir(opts.sampleRoot).catch(() => [])) {
152
+ if (name.startsWith("app.old-")) {
153
+ await fs.promises.rm(path.join(opts.sampleRoot, name), { recursive: true, force: true }).catch(() => { });
154
+ }
155
+ }
74
156
  const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
75
157
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
76
158
  await fs.promises.mkdir(tmpDir, { recursive: true });
77
159
  try {
78
- for (const file of entry.files) {
160
+ // Fetched concurrently: the files are independent and each is a separate
161
+ // round-trip to the same host, so serializing them only added latency.
162
+ await Promise.all(entry.files.map(async (file) => {
79
163
  const text = await fetchText(opts.fetchFn, livewareFileUrl(file.path, ref, opts.base));
80
164
  const buf = Buffer.from(text, "utf8");
81
165
  const actual = crypto.createHash("sha256").update(buf).digest("hex");
@@ -83,7 +167,7 @@ export async function downloadLivewareSample(opts) {
83
167
  throw new Error(`sha256 mismatch for ${file.path}: expected ${file.sha256} got ${actual}`);
84
168
  }
85
169
  await fs.promises.writeFile(path.join(tmpDir, path.basename(file.path)), buf);
86
- }
170
+ }));
87
171
  // Preserve agent/user-owned data files from a previous install.
88
172
  for (const name of USER_DATA_FILES) {
89
173
  const prev = path.join(appDir, name);
@@ -91,8 +175,18 @@ export async function downloadLivewareSample(opts) {
91
175
  await fs.promises.copyFile(prev, path.join(tmpDir, name));
92
176
  }
93
177
  }
94
- await fs.promises.rm(appDir, { recursive: true, force: true });
95
- await fs.promises.rename(tmpDir, appDir);
178
+ // Swap by moving the old tree ASIDE rather than deleting it first. On
179
+ // Windows the delete is the step most likely to lose a race with an open
180
+ // handle, and doing it first leaves no app/ at all when it fails. Renaming
181
+ // aside puts the new tree in place first and makes the delete best-effort:
182
+ // a leftover app.old-<pid> is swept on the next run, never a broken install.
183
+ const oldDir = path.join(opts.sampleRoot, `app.old-${process.pid}`);
184
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => { });
185
+ if (fs.existsSync(appDir)) {
186
+ await withFsSwapRetry(() => fs.promises.rename(appDir, oldDir));
187
+ }
188
+ await withFsSwapRetry(() => fs.promises.rename(tmpDir, appDir));
189
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => { });
96
190
  return { version: entry.version, appDir };
97
191
  }
98
192
  catch (err) {
@@ -100,6 +194,35 @@ export async function downloadLivewareSample(opts) {
100
194
  throw err;
101
195
  }
102
196
  }
197
+ /**
198
+ * Terminate a child and everything it spawned.
199
+ *
200
+ * POSIX: `SIGTERM` to the child is what we want and what the child expects.
201
+ *
202
+ * Windows: there are no signals. `child.kill()` maps to TerminateProcess on
203
+ * that one process, so any grandchild the liveware agent spawned survives —
204
+ * orphaned, still holding the sample port, and invisible to the next boot,
205
+ * which then fails to bind. `taskkill /T /F` takes the whole tree down.
206
+ * Fire-and-forget: we never wait on it, and a failure (already dead, no such
207
+ * pid) falls through to the ordinary kill().
208
+ */
209
+ export function killProcessTree(child, execFileFn = nodeExecFile) {
210
+ if (!child)
211
+ return;
212
+ if (process.platform === "win32" && typeof child.pid === "number") {
213
+ try {
214
+ execFileFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => { });
215
+ return;
216
+ }
217
+ catch {
218
+ // taskkill missing or spawn refused — fall back to the plain kill below.
219
+ }
220
+ }
221
+ try {
222
+ child.kill("SIGTERM");
223
+ }
224
+ catch { /* already dead */ }
225
+ }
103
226
  const SERVER_START_TIMEOUT_MS = 10_000;
104
227
  const TUNNEL_START_TIMEOUT_MS = 30_000;
105
228
  const CLI_TIMEOUT_MS = 30_000;
@@ -117,10 +240,7 @@ function waitForOutput(child, match, timeoutMs, label) {
117
240
  child.stderr?.removeListener("data", onData);
118
241
  child.removeListener("exit", onExit);
119
242
  if (err) {
120
- try {
121
- child.kill("SIGTERM");
122
- }
123
- catch { /* already dead */ }
243
+ killProcessTree(child);
124
244
  reject(err);
125
245
  }
126
246
  else {
@@ -276,6 +396,87 @@ function findIdDeep(value) {
276
396
  }
277
397
  return null;
278
398
  }
399
+ /**
400
+ * Find the id of an existing liveware app named `name` in `liveware app list`
401
+ * output. Deliberately conservative: a shape this cannot read must degrade to
402
+ * "not found" — the caller then creates an app, i.e. the previous behaviour —
403
+ * and must never return a WRONG id, so the text branch only accepts a line that
404
+ * contains the exact app name plus one id-looking token outside it, and an
405
+ * id-looking token must carry a digit/`-`/`_` so a status word ("active",
406
+ * "running") cannot pass for an id. JSON output (any wrapper key) is the
407
+ * reliable path; the aligned-table branch is calibrated on `app create`'s table,
408
+ * the only text shape verified against the real CLI.
409
+ */
410
+ export function findAppIdByName(stdout, name) {
411
+ const text = (stdout ?? "").trim();
412
+ const wanted = (name ?? "").trim();
413
+ if (!text || !wanted)
414
+ return null;
415
+ try {
416
+ const found = findNamedIdDeep(JSON.parse(text), wanted);
417
+ if (found)
418
+ return found;
419
+ }
420
+ catch { /* not JSON */ }
421
+ for (const line of text.split("\n")) {
422
+ const at = line.indexOf(wanted);
423
+ if (at < 0)
424
+ continue;
425
+ const outsideName = `${line.slice(0, at)} ${line.slice(at + wanted.length)}`;
426
+ for (const token of outsideName.trim().split(/\s+/)) {
427
+ if (isAppIdToken(token))
428
+ return token;
429
+ }
430
+ }
431
+ return null;
432
+ }
433
+ function isAppIdToken(token) {
434
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{5,}$/.test(token) && /[0-9_-]/.test(token);
435
+ }
436
+ function findNamedIdDeep(value, name) {
437
+ if (Array.isArray(value)) {
438
+ for (const item of value) {
439
+ const found = findNamedIdDeep(item, name);
440
+ if (found)
441
+ return found;
442
+ }
443
+ return null;
444
+ }
445
+ if (!value || typeof value !== "object")
446
+ return null;
447
+ const obj = value;
448
+ const named = ["name", "app_name", "appName", "title"].some((key) => typeof obj[key] === "string" && obj[key].trim() === name);
449
+ if (named) {
450
+ const id = findIdDeep(obj);
451
+ if (id)
452
+ return id;
453
+ }
454
+ for (const nested of Object.values(obj)) {
455
+ const found = findNamedIdDeep(nested, name);
456
+ if (found)
457
+ return found;
458
+ }
459
+ return null;
460
+ }
461
+ /**
462
+ * `liveware app list` → the id of an existing app with this name, or null.
463
+ * Best-effort by contract: an exec failure or unreadable output resolves to null
464
+ * so the caller falls back to `app create` instead of failing the whole flow.
465
+ */
466
+ export async function livewareAppFindByName(opts) {
467
+ const execFileFn = opts.execFileFn ?? nodeExecFile;
468
+ const stdout = await new Promise((resolve) => {
469
+ execFileFn(opts.livewarePath, ["app", "list"], { timeout: CLI_TIMEOUT_MS }, (err, out, errOut) => {
470
+ if (err) {
471
+ opts.log?.debug?.(`liveware-sample: app list failed; falling back to app create: ${String(errOut || out || err)}`);
472
+ resolve(null);
473
+ return;
474
+ }
475
+ resolve(String(out ?? ""));
476
+ });
477
+ });
478
+ return stdout == null ? null : findAppIdByName(stdout, opts.name);
479
+ }
279
480
  export async function livewareAppCreate(opts) {
280
481
  const execFileFn = opts.execFileFn ?? nodeExecFile;
281
482
  const stdout = await new Promise((resolve, reject) => {
@@ -305,10 +506,15 @@ const INTRO_MAX_TRIES = 20;
305
506
  // a one-shot bootstrap would otherwise stay dormant until the next restart.
306
507
  const START_RETRY_DELAYS_MS = [30_000, 60_000, 120_000, 300_000];
307
508
  export class LivewareSampleSupervisor {
509
+ /** Not readonly: adoptDeps() re-points a live supervisor at a re-entered
510
+ * gateway closure (fresh token / REST client / owner-notify path). */
308
511
  deps;
309
512
  serverChild = null;
310
513
  tunnelChild = null;
311
514
  stopped = false;
515
+ /** True while a startAttempt chain (bootstrap/relaunch) is running; read by
516
+ * isIdle() so adoptDeps never starts a second concurrent flow. */
517
+ launchInFlight = false;
312
518
  restartTimes = [];
313
519
  timers = new Set();
314
520
  /** Bumped on every (re)launch and on killChildren: a child's exit handler
@@ -323,8 +529,39 @@ export class LivewareSampleSupervisor {
323
529
  async start() {
324
530
  await this.startAttempt(0);
325
531
  }
532
+ /** Re-point a live supervisor at a freshly re-entered gateway closure, and —
533
+ * only when nothing is in flight — kick a new attempt.
534
+ *
535
+ * `startOpenclawClawlingGateway` re-enters itself on token refresh / transport
536
+ * backoff, so runtime.ts keeps ONE supervisor per (abortSignal, account) and
537
+ * hands the new closure here instead of constructing a second supervisor: two
538
+ * supervisors raced the bootstrap "account has zero apps" gate and registered
539
+ * two liveware apps for the same account, one of which no row then tracked.
540
+ * The re-entered closure owns the fresh token, REST client and owner-notify
541
+ * path; children, timers and the crash-restart budget keep running untouched.
542
+ * A flow already mid-await finishes on the deps it captured. */
543
+ adoptDeps(deps) {
544
+ this.deps = deps;
545
+ // A re-entry used to re-run the whole flow through the brand-new supervisor,
546
+ // which is what healed an attempt that had skipped (no token yet) or burnt
547
+ // through its retry ladder. Keep that healing, but only when there is
548
+ // genuinely nothing live: no in-flight flow, no pending retry/crash-restart
549
+ // timer, no children — a second concurrent flow is exactly the duplicate
550
+ // registration the single-supervisor registry exists to prevent.
551
+ if (this.isIdle())
552
+ void this.startAttempt(0);
553
+ }
554
+ /** Nothing running and nothing scheduled: safe to (re)start a flow. */
555
+ isIdle() {
556
+ return !this.stopped
557
+ && !this.launchInFlight
558
+ && this.timers.size === 0
559
+ && this.serverChild === null
560
+ && this.tunnelChild === null;
561
+ }
326
562
  async startAttempt(attempt) {
327
563
  const { deps } = this;
564
+ this.launchInFlight = true;
328
565
  try {
329
566
  if (!deps.enabled)
330
567
  return;
@@ -357,6 +594,9 @@ export class LivewareSampleSupervisor {
357
594
  deps.log?.warn?.(`clawchat-plugin-openclaw liveware-sample start failed (attempt ${attempt + 1}, retrying in ${delayMs / 1000}s): ${String(err)}`);
358
595
  this.schedule(() => { void this.startAttempt(attempt + 1); }, delayMs);
359
596
  }
597
+ finally {
598
+ this.launchInFlight = false;
599
+ }
360
600
  }
361
601
  stop() {
362
602
  this.stopped = true;
@@ -368,10 +608,7 @@ export class LivewareSampleSupervisor {
368
608
  killChildren() {
369
609
  this.generation += 1; // invalidate pending exit handlers before killing
370
610
  for (const child of [this.serverChild, this.tunnelChild]) {
371
- try {
372
- child?.kill("SIGTERM");
373
- }
374
- catch { /* already dead */ }
611
+ killProcessTree(child, this.deps.execFileFn);
375
612
  }
376
613
  this.serverChild = null;
377
614
  this.tunnelChild = null;
@@ -396,24 +633,18 @@ export class LivewareSampleSupervisor {
396
633
  const { deps } = this;
397
634
  if (this.stopped)
398
635
  return;
636
+ // Retryable-vs-opt-out: a missing CLI, an absent token and a flaky listApps
637
+ // are all transient, but they used to `return`, which left the sample dormant
638
+ // for the whole process lifetime (nothing re-enters bootstrap on its own).
639
+ // Throw instead so startAttempt's START_RETRY_DELAYS_MS ladder gets its turn;
640
+ // only a genuine opt-out (disabled row, owner already has apps) returns.
399
641
  const livewarePath = deps.resolveLivewarePath();
400
- if (!livewarePath) {
401
- deps.log?.warn?.("liveware-sample: liveware CLI not ready; skip");
402
- return;
403
- }
642
+ if (!livewarePath)
643
+ throw new Error("liveware CLI not ready");
404
644
  const token = deps.resolveToken();
405
- if (!token) {
406
- deps.log?.debug?.("liveware-sample: no token; skip");
407
- return;
408
- }
409
- let apps;
410
- try {
411
- apps = await deps.listApps();
412
- }
413
- catch (err) {
414
- deps.log?.debug?.(`liveware-sample: listApps failed; skip bootstrap: ${String(err)}`);
415
- return;
416
- }
645
+ if (!token)
646
+ throw new Error("no ClawChat token available yet");
647
+ const apps = await deps.listApps();
417
648
  if (this.bailIfStopped())
418
649
  return;
419
650
  if (apps.apps.length > 0) {
@@ -433,9 +664,31 @@ export class LivewareSampleSupervisor {
433
664
  if (this.bailIfStopped())
434
665
  return;
435
666
  await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
436
- const appId = await livewareAppCreate({
667
+ // Reuse an app we already own before minting another one. `app create` is not
668
+ // idempotent and nothing here ever reconciled against the liveware side, so
669
+ // every bootstrap that died past this point (no row was written until the very
670
+ // end, see below) left one more orphan app behind and ate into the account's
671
+ // app quota. Best-effort: an unreadable list falls through to `app create`.
672
+ const existingAppId = await livewareAppFindByName({
673
+ livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
674
+ });
675
+ if (this.bailIfStopped())
676
+ return;
677
+ if (existingAppId) {
678
+ deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
679
+ }
680
+ const appId = existingAppId ?? await livewareAppCreate({
437
681
  livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
438
682
  });
683
+ // Persist the app id BEFORE the steps that can still fail. Without this a
684
+ // failure anywhere below left no row at all, so the next boot ran bootstrap
685
+ // again and created yet another app; a "pending" row makes the next attempt
686
+ // resume through relaunch() with the same app id instead.
687
+ deps.store.upsertLivewareSample({
688
+ platform: deps.platform, accountId: deps.accountId,
689
+ appId, appName: LIVEWARE_SAMPLE_APP_NAME, port: server.port,
690
+ publicUrl: null, sampleVersion: version, status: "pending",
691
+ });
439
692
  const publicUrl = await tunnelBind({
440
693
  livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
441
694
  });
@@ -459,24 +712,23 @@ export class LivewareSampleSupervisor {
459
712
  const { deps } = this;
460
713
  if (this.stopped)
461
714
  return;
715
+ // Both of these used to `return` and strand the sample until the next process
716
+ // start; they are transient, so throw and let the retry ladder run (see
717
+ // bootstrap). Only a successful list that no longer contains the app is an
718
+ // opt-out.
462
719
  const livewarePath = deps.resolveLivewarePath();
463
- if (!livewarePath) {
464
- deps.log?.warn?.("liveware-sample: liveware CLI not ready; skip relaunch");
465
- return;
466
- }
720
+ if (!livewarePath)
721
+ throw new Error("liveware CLI not ready");
722
+ const apps = await deps.listApps();
467
723
  // The user deleting the app tile is an explicit opt-out — never reinstall.
468
- try {
469
- const apps = await deps.listApps();
470
- if (!apps.apps.some((a) => a.app_id === row.app_id)) {
471
- deps.store.updateLivewareSampleStatus({
472
- platform: deps.platform, accountId: deps.accountId, status: "disabled",
473
- lastError: "app removed by user",
474
- });
475
- return;
476
- }
477
- }
478
- catch (err) {
479
- deps.log?.debug?.(`liveware-sample: listApps failed; skip relaunch: ${String(err)}`);
724
+ // Exception: a "pending" row was persisted mid-bootstrap, before the app was
725
+ // ever registered with ClawChat, so its absence from this list means "not
726
+ // registered yet", not "deleted" — disabling it there would strand the app id.
727
+ if (row.status !== "pending" && !apps.apps.some((a) => a.app_id === row.app_id)) {
728
+ deps.store.updateLivewareSampleStatus({
729
+ platform: deps.platform, accountId: deps.accountId, status: "disabled",
730
+ lastError: "app removed by user",
731
+ });
480
732
  return;
481
733
  }
482
734
  if (this.bailIfStopped())
@@ -504,8 +756,46 @@ export class LivewareSampleSupervisor {
504
756
  this.serverChild = server.child;
505
757
  if (this.bailIfStopped())
506
758
  return;
759
+ // Re-login before touching the CLI. Its credentials live in $HOME/.clawling,
760
+ // a different volume from the stateDir holding the row that sent us down this
761
+ // path: a container that persists stateDir but not $HOME comes back with a row
762
+ // AND a logged-out CLI, so every tunnelBind/agent below fails auth until the
763
+ // retry ladder gives up — the sample then never returns for the whole process
764
+ // lifetime. `login` is idempotent; best-effort, so a stale token still falls
765
+ // through to whatever credentials the CLI already has.
766
+ const token = deps.resolveToken();
767
+ if (token) {
768
+ try {
769
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
770
+ }
771
+ catch (err) {
772
+ deps.log?.warn?.(`liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`);
773
+ }
774
+ if (this.bailIfStopped())
775
+ return;
776
+ }
777
+ else {
778
+ deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
779
+ }
780
+ // Kept after the re-login above so the lookup runs against a CLI that is
781
+ // actually authenticated: a pending row's app id came straight from
782
+ // `app create`'s parser and was never confirmed by the liveware side, so
783
+ // prefer a by-name lookup — a mis-parsed id then self-heals instead of
784
+ // failing every tunnel bind for the rest of this row's life.
785
+ let appId = row.app_id;
786
+ if (row.status === "pending") {
787
+ const found = await livewareAppFindByName({
788
+ livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
789
+ });
790
+ if (found && found !== appId) {
791
+ deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
792
+ appId = found;
793
+ }
794
+ if (this.bailIfStopped())
795
+ return;
796
+ }
507
797
  const publicUrl = await tunnelBind({
508
- livewarePath, appId: row.app_id, port: server.port, execFileFn: deps.execFileFn,
798
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
509
799
  });
510
800
  if (this.bailIfStopped())
511
801
  return;
@@ -513,14 +803,16 @@ export class LivewareSampleSupervisor {
513
803
  this.tunnelChild = agent.child;
514
804
  if (this.bailIfStopped())
515
805
  return;
516
- if (publicUrl !== row.public_url) {
806
+ // A pending row has never been registered with ClawChat (public_url is null),
807
+ // so this condition also covers "register it for the first time".
808
+ if (publicUrl !== row.public_url || appId !== row.app_id) {
517
809
  await deps.registerApp({
518
- name: row.app_name, appId: row.app_id, url: publicUrl,
810
+ name: row.app_name, appId, url: publicUrl,
519
811
  });
520
812
  }
521
813
  deps.store.upsertLivewareSample({
522
814
  platform: deps.platform, accountId: deps.accountId,
523
- appId: row.app_id, appName: row.app_name, port: server.port,
815
+ appId, appName: row.app_name, port: server.port,
524
816
  publicUrl, sampleVersion: version, status: "active",
525
817
  });
526
818
  this.watchChildren();
@@ -553,7 +845,15 @@ export class LivewareSampleSupervisor {
553
845
  });
554
846
  if (!row || row.status === "disabled")
555
847
  return;
556
- void this.relaunch(row).catch((err) => {
848
+ // This relaunch does NOT go through startAttempt, so it must do that
849
+ // method's launchInFlight bookkeeping itself: the timer has already been
850
+ // dropped from `timers` and children are only assigned a few awaits in, so
851
+ // without this isIdle() reports idle for the whole early part of a
852
+ // crash-restart and a concurrent adoptDeps kick would run a SECOND launch
853
+ // flow — the duplicate registration the single-supervisor rule prevents.
854
+ this.launchInFlight = true;
855
+ void this.relaunch(row)
856
+ .catch((err) => {
557
857
  // A partially-completed relaunch may have spawned children before
558
858
  // failing — never leave them running unwatched.
559
859
  this.killChildren();
@@ -562,7 +862,8 @@ export class LivewareSampleSupervisor {
562
862
  platform: this.deps.platform, accountId: this.deps.accountId,
563
863
  status: "failed", lastError: String(err),
564
864
  });
565
- });
865
+ })
866
+ .finally(() => { this.launchInFlight = false; });
566
867
  }, delay);
567
868
  };
568
869
  for (const child of [this.serverChild, this.tunnelChild]) {