@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.
@@ -20,6 +20,36 @@ export const MAX_SAMPLE_FILE_BYTES = 512 * 1024;
20
20
  /** Files the agent owns at runtime; preserved across sample upgrades. */
21
21
  const USER_DATA_FILES = ["state.json", "events.jsonl"];
22
22
 
23
+ /**
24
+ * Windows refuses to unlink or rename a directory while ANY file inside it is
25
+ * still open, and reports it as a sharing violation (EPERM/EBUSY/EACCES) or a
26
+ * non-empty directory. A still-draining sample server, an antivirus scan or the
27
+ * search indexer is enough to trigger it, and all of those clear within
28
+ * milliseconds — so a short bounded retry turns a hard install failure into a
29
+ * brief pause. On POSIX these codes essentially never appear here and the first
30
+ * attempt always wins, so the retry costs nothing.
31
+ */
32
+ const FS_SWAP_RETRY_DELAYS_MS = [50, 150, 400];
33
+ const TRANSIENT_FS_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
34
+
35
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
36
+
37
+ async function withFsSwapRetry<T>(op: () => Promise<T>): Promise<T> {
38
+ let lastError: unknown;
39
+ for (let attempt = 0; attempt <= FS_SWAP_RETRY_DELAYS_MS.length; attempt += 1) {
40
+ try {
41
+ return await op();
42
+ } catch (err) {
43
+ lastError = err;
44
+ const code = (err as NodeJS.ErrnoException).code ?? "";
45
+ const delay = FS_SWAP_RETRY_DELAYS_MS[attempt];
46
+ if (!TRANSIENT_FS_CODES.has(code) || delay === undefined) break;
47
+ await sleep(delay);
48
+ }
49
+ }
50
+ throw lastError;
51
+ }
52
+
23
53
  export interface LivewareSampleManifestFile {
24
54
  path: string;
25
55
  sha256: string;
@@ -88,28 +118,85 @@ async function fetchText(fetchFn: FetchLike, url: string): Promise<string> {
88
118
  return text;
89
119
  }
90
120
 
121
+ /**
122
+ * True when `appDir` already holds what the manifest pins: every listed file
123
+ * present, program files byte-identical to their manifest sha256, and nothing
124
+ * beyond those files (so a file dropped by a newer manifest still forces a clean
125
+ * reinstall). USER_DATA_FILES are checked for presence only, never for content —
126
+ * `state.json` ships in the manifest *and* is owned by the agent afterwards, so
127
+ * hashing it would make the short-circuit miss on exactly the installs that are
128
+ * actually in use. Cheap (~20 KB of local reads) next to the per-file GETs it
129
+ * saves, and fail-safe: any read error answers "no".
130
+ */
131
+ async function localInstallMatches(
132
+ appDir: string,
133
+ entry: LivewareSampleManifestEntry,
134
+ ): Promise<boolean> {
135
+ try {
136
+ const expected = new Set(entry.files.map((f) => path.basename(f.path)));
137
+ for (const name of await fs.promises.readdir(appDir)) {
138
+ if (!expected.has(name) && !USER_DATA_FILES.includes(name)) return false;
139
+ }
140
+ for (const file of entry.files) {
141
+ const name = path.basename(file.path);
142
+ const target = path.join(appDir, name);
143
+ if (USER_DATA_FILES.includes(name)) {
144
+ await fs.promises.access(target); // agent-owned: presence is the whole check
145
+ continue;
146
+ }
147
+ const buf = await fs.promises.readFile(target);
148
+ if (crypto.createHash("sha256").update(buf).digest("hex") !== file.sha256) return false;
149
+ }
150
+ return true;
151
+ } catch {
152
+ return false; // missing / unreadable — treat as "not installed"
153
+ }
154
+ }
155
+
91
156
  /**
92
157
  * Download + verify + atomically install the sample under `<sampleRoot>/app`.
93
158
  * Preserves USER_DATA_FILES from an existing install. Throws on any failure;
94
159
  * never leaves a partially-written `app/` dir behind.
160
+ *
161
+ * Re-install short-circuit: when the installed bytes already match every hash
162
+ * the manifest pins, nothing is fetched or rewritten (`reused: true`). Every
163
+ * boot used to re-fetch the whole bundle from GitHub raw even when the identical
164
+ * files were already on disk — a handful of serial round-trips on the critical
165
+ * path, and an unreachable raw.githubusercontent then failed a sample that was
166
+ * already fully installed. Only the manifest itself is still fetched every time,
167
+ * because that is what tells us which hashes to compare against.
95
168
  */
96
169
  export async function downloadLivewareSample(opts: {
97
170
  fetchFn: FetchLike;
98
171
  sampleRoot: string;
99
172
  ref?: string;
100
173
  base?: string;
101
- }): Promise<{ version: string; appDir: string }> {
174
+ }): Promise<{ version: string; appDir: string; reused?: boolean }> {
102
175
  const ref = opts.ref ?? DEFAULT_SKILLS_REF;
103
176
  const entry = parseLivewaresManifest(
104
177
  await fetchText(opts.fetchFn, livewaresManifestUrl(ref, opts.base)),
105
178
  );
106
179
 
107
180
  const appDir = path.join(opts.sampleRoot, "app");
181
+ if (await localInstallMatches(appDir, entry)) {
182
+ return { version: entry.version, appDir, reused: true };
183
+ }
184
+ // Sweep app.old-* left behind when a previous swap could not delete the old
185
+ // tree (a handle was still open then, but is not now). Best effort: a dir we
186
+ // still cannot remove is simply retried next run.
187
+ for (const name of await fs.promises.readdir(opts.sampleRoot).catch(() => [])) {
188
+ if (name.startsWith("app.old-")) {
189
+ await fs.promises.rm(path.join(opts.sampleRoot, name), { recursive: true, force: true }).catch(() => {});
190
+ }
191
+ }
192
+
108
193
  const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
109
194
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
110
195
  await fs.promises.mkdir(tmpDir, { recursive: true });
111
196
  try {
112
- for (const file of entry.files) {
197
+ // Fetched concurrently: the files are independent and each is a separate
198
+ // round-trip to the same host, so serializing them only added latency.
199
+ await Promise.all(entry.files.map(async (file) => {
113
200
  const text = await fetchText(opts.fetchFn, livewareFileUrl(file.path, ref, opts.base));
114
201
  const buf = Buffer.from(text, "utf8");
115
202
  const actual = crypto.createHash("sha256").update(buf).digest("hex");
@@ -117,7 +204,7 @@ export async function downloadLivewareSample(opts: {
117
204
  throw new Error(`sha256 mismatch for ${file.path}: expected ${file.sha256} got ${actual}`);
118
205
  }
119
206
  await fs.promises.writeFile(path.join(tmpDir, path.basename(file.path)), buf);
120
- }
207
+ }));
121
208
  // Preserve agent/user-owned data files from a previous install.
122
209
  for (const name of USER_DATA_FILES) {
123
210
  const prev = path.join(appDir, name);
@@ -125,8 +212,18 @@ export async function downloadLivewareSample(opts: {
125
212
  await fs.promises.copyFile(prev, path.join(tmpDir, name));
126
213
  }
127
214
  }
128
- await fs.promises.rm(appDir, { recursive: true, force: true });
129
- await fs.promises.rename(tmpDir, appDir);
215
+ // Swap by moving the old tree ASIDE rather than deleting it first. On
216
+ // Windows the delete is the step most likely to lose a race with an open
217
+ // handle, and doing it first leaves no app/ at all when it fails. Renaming
218
+ // aside puts the new tree in place first and makes the delete best-effort:
219
+ // a leftover app.old-<pid> is swept on the next run, never a broken install.
220
+ const oldDir = path.join(opts.sampleRoot, `app.old-${process.pid}`);
221
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => {});
222
+ if (fs.existsSync(appDir)) {
223
+ await withFsSwapRetry(() => fs.promises.rename(appDir, oldDir));
224
+ }
225
+ await withFsSwapRetry(() => fs.promises.rename(tmpDir, appDir));
226
+ await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => {});
130
227
  return { version: entry.version, appDir };
131
228
  } catch (err) {
132
229
  await fs.promises.rm(tmpDir, { recursive: true, force: true });
@@ -137,6 +234,34 @@ export async function downloadLivewareSample(opts: {
137
234
  export type SpawnLike = typeof nodeSpawn;
138
235
  export type ExecFileLike = typeof nodeExecFile;
139
236
 
237
+ /**
238
+ * Terminate a child and everything it spawned.
239
+ *
240
+ * POSIX: `SIGTERM` to the child is what we want and what the child expects.
241
+ *
242
+ * Windows: there are no signals. `child.kill()` maps to TerminateProcess on
243
+ * that one process, so any grandchild the liveware agent spawned survives —
244
+ * orphaned, still holding the sample port, and invisible to the next boot,
245
+ * which then fails to bind. `taskkill /T /F` takes the whole tree down.
246
+ * Fire-and-forget: we never wait on it, and a failure (already dead, no such
247
+ * pid) falls through to the ordinary kill().
248
+ */
249
+ export function killProcessTree(
250
+ child: ChildProcess | null | undefined,
251
+ execFileFn: ExecFileLike = nodeExecFile,
252
+ ): void {
253
+ if (!child) return;
254
+ if (process.platform === "win32" && typeof child.pid === "number") {
255
+ try {
256
+ execFileFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => {});
257
+ return;
258
+ } catch {
259
+ // taskkill missing or spawn refused — fall back to the plain kill below.
260
+ }
261
+ }
262
+ try { child.kill("SIGTERM"); } catch { /* already dead */ }
263
+ }
264
+
140
265
  const SERVER_START_TIMEOUT_MS = 10_000;
141
266
  const TUNNEL_START_TIMEOUT_MS = 30_000;
142
267
  const CLI_TIMEOUT_MS = 30_000;
@@ -159,7 +284,7 @@ function waitForOutput(
159
284
  child.stderr?.removeListener("data", onData);
160
285
  child.removeListener("exit", onExit);
161
286
  if (err) {
162
- try { child.kill("SIGTERM"); } catch { /* already dead */ }
287
+ killProcessTree(child);
163
288
  reject(err);
164
289
  } else {
165
290
  resolve(value as string);
@@ -351,6 +476,96 @@ function findIdDeep(value: unknown): string | null {
351
476
  return null;
352
477
  }
353
478
 
479
+ /**
480
+ * Find the id of an existing liveware app named `name` in `liveware app list`
481
+ * output. Deliberately conservative: a shape this cannot read must degrade to
482
+ * "not found" — the caller then creates an app, i.e. the previous behaviour —
483
+ * and must never return a WRONG id, so the text branch only accepts a line that
484
+ * contains the exact app name plus one id-looking token outside it, and an
485
+ * id-looking token must carry a digit/`-`/`_` so a status word ("active",
486
+ * "running") cannot pass for an id. JSON output (any wrapper key) is the
487
+ * reliable path; the aligned-table branch is calibrated on `app create`'s table,
488
+ * the only text shape verified against the real CLI.
489
+ */
490
+ export function findAppIdByName(stdout: string, name: string): string | null {
491
+ const text = (stdout ?? "").trim();
492
+ const wanted = (name ?? "").trim();
493
+ if (!text || !wanted) return null;
494
+ try {
495
+ const found = findNamedIdDeep(JSON.parse(text), wanted);
496
+ if (found) return found;
497
+ } catch { /* not JSON */ }
498
+ for (const line of text.split("\n")) {
499
+ const at = line.indexOf(wanted);
500
+ if (at < 0) continue;
501
+ const outsideName = `${line.slice(0, at)} ${line.slice(at + wanted.length)}`;
502
+ for (const token of outsideName.trim().split(/\s+/)) {
503
+ if (isAppIdToken(token)) return token;
504
+ }
505
+ }
506
+ return null;
507
+ }
508
+
509
+ function isAppIdToken(token: string): boolean {
510
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{5,}$/.test(token) && /[0-9_-]/.test(token);
511
+ }
512
+
513
+ function findNamedIdDeep(value: unknown, name: string): string | null {
514
+ if (Array.isArray(value)) {
515
+ for (const item of value) {
516
+ const found = findNamedIdDeep(item, name);
517
+ if (found) return found;
518
+ }
519
+ return null;
520
+ }
521
+ if (!value || typeof value !== "object") return null;
522
+ const obj = value as Record<string, unknown>;
523
+ const named = ["name", "app_name", "appName", "title"].some(
524
+ (key) => typeof obj[key] === "string" && (obj[key] as string).trim() === name,
525
+ );
526
+ if (named) {
527
+ const id = findIdDeep(obj);
528
+ if (id) return id;
529
+ }
530
+ for (const nested of Object.values(obj)) {
531
+ const found = findNamedIdDeep(nested, name);
532
+ if (found) return found;
533
+ }
534
+ return null;
535
+ }
536
+
537
+ /**
538
+ * `liveware app list` → the id of an existing app with this name, or null.
539
+ * Best-effort by contract: an exec failure or unreadable output resolves to null
540
+ * so the caller falls back to `app create` instead of failing the whole flow.
541
+ */
542
+ export async function livewareAppFindByName(opts: {
543
+ livewarePath: string;
544
+ name: string;
545
+ execFileFn?: ExecFileLike;
546
+ log?: LivewareLogger;
547
+ }): Promise<string | null> {
548
+ const execFileFn = opts.execFileFn ?? nodeExecFile;
549
+ const stdout = await new Promise<string | null>((resolve) => {
550
+ execFileFn(
551
+ opts.livewarePath,
552
+ ["app", "list"],
553
+ { timeout: CLI_TIMEOUT_MS },
554
+ (err, out, errOut) => {
555
+ if (err) {
556
+ opts.log?.debug?.(
557
+ `liveware-sample: app list failed; falling back to app create: ${String(errOut || out || err)}`,
558
+ );
559
+ resolve(null);
560
+ return;
561
+ }
562
+ resolve(String(out ?? ""));
563
+ },
564
+ );
565
+ });
566
+ return stdout == null ? null : findAppIdByName(stdout, opts.name);
567
+ }
568
+
354
569
  export async function livewareAppCreate(opts: {
355
570
  livewarePath: string;
356
571
  name: string;
@@ -379,7 +594,7 @@ export type LivewareSampleStoreLike = {
379
594
  updateLivewareSampleStatus(input: {
380
595
  platform: string;
381
596
  accountId: string;
382
- status: "active" | "failed" | "disabled";
597
+ status: "pending" | "active" | "failed" | "disabled";
383
598
  lastError?: string | null;
384
599
  }): void;
385
600
  markLivewareSampleIntroSent(input: { platform: string; accountId: string }): void;
@@ -428,10 +643,15 @@ const INTRO_MAX_TRIES = 20;
428
643
  const START_RETRY_DELAYS_MS = [30_000, 60_000, 120_000, 300_000];
429
644
 
430
645
  export class LivewareSampleSupervisor {
431
- private readonly deps: LivewareSampleSupervisorDeps;
646
+ /** Not readonly: adoptDeps() re-points a live supervisor at a re-entered
647
+ * gateway closure (fresh token / REST client / owner-notify path). */
648
+ private deps: LivewareSampleSupervisorDeps;
432
649
  private serverChild: ChildProcess | null = null;
433
650
  private tunnelChild: ChildProcess | null = null;
434
651
  private stopped = false;
652
+ /** True while a startAttempt chain (bootstrap/relaunch) is running; read by
653
+ * isIdle() so adoptDeps never starts a second concurrent flow. */
654
+ private launchInFlight = false;
435
655
  private restartTimes: number[] = [];
436
656
  private timers = new Set<ReturnType<typeof setTimeout>>();
437
657
  /** Bumped on every (re)launch and on killChildren: a child's exit handler
@@ -449,8 +669,40 @@ export class LivewareSampleSupervisor {
449
669
  await this.startAttempt(0);
450
670
  }
451
671
 
672
+ /** Re-point a live supervisor at a freshly re-entered gateway closure, and —
673
+ * only when nothing is in flight — kick a new attempt.
674
+ *
675
+ * `startOpenclawClawlingGateway` re-enters itself on token refresh / transport
676
+ * backoff, so runtime.ts keeps ONE supervisor per (abortSignal, account) and
677
+ * hands the new closure here instead of constructing a second supervisor: two
678
+ * supervisors raced the bootstrap "account has zero apps" gate and registered
679
+ * two liveware apps for the same account, one of which no row then tracked.
680
+ * The re-entered closure owns the fresh token, REST client and owner-notify
681
+ * path; children, timers and the crash-restart budget keep running untouched.
682
+ * A flow already mid-await finishes on the deps it captured. */
683
+ adoptDeps(deps: LivewareSampleSupervisorDeps): void {
684
+ this.deps = deps;
685
+ // A re-entry used to re-run the whole flow through the brand-new supervisor,
686
+ // which is what healed an attempt that had skipped (no token yet) or burnt
687
+ // through its retry ladder. Keep that healing, but only when there is
688
+ // genuinely nothing live: no in-flight flow, no pending retry/crash-restart
689
+ // timer, no children — a second concurrent flow is exactly the duplicate
690
+ // registration the single-supervisor registry exists to prevent.
691
+ if (this.isIdle()) void this.startAttempt(0);
692
+ }
693
+
694
+ /** Nothing running and nothing scheduled: safe to (re)start a flow. */
695
+ private isIdle(): boolean {
696
+ return !this.stopped
697
+ && !this.launchInFlight
698
+ && this.timers.size === 0
699
+ && this.serverChild === null
700
+ && this.tunnelChild === null;
701
+ }
702
+
452
703
  private async startAttempt(attempt: number): Promise<void> {
453
704
  const { deps } = this;
705
+ this.launchInFlight = true;
454
706
  try {
455
707
  if (!deps.enabled) return;
456
708
  // ensureLivewareCli downloads the CLI asynchronously on first boot;
@@ -480,6 +732,8 @@ export class LivewareSampleSupervisor {
480
732
  `clawchat-plugin-openclaw liveware-sample start failed (attempt ${attempt + 1}, retrying in ${delayMs / 1000}s): ${String(err)}`,
481
733
  );
482
734
  this.schedule(() => { void this.startAttempt(attempt + 1); }, delayMs);
735
+ } finally {
736
+ this.launchInFlight = false;
483
737
  }
484
738
  }
485
739
 
@@ -493,7 +747,7 @@ export class LivewareSampleSupervisor {
493
747
  private killChildren(): void {
494
748
  this.generation += 1; // invalidate pending exit handlers before killing
495
749
  for (const child of [this.serverChild, this.tunnelChild]) {
496
- try { child?.kill("SIGTERM"); } catch { /* already dead */ }
750
+ killProcessTree(child, this.deps.execFileFn);
497
751
  }
498
752
  this.serverChild = null;
499
753
  this.tunnelChild = null;
@@ -518,17 +772,16 @@ export class LivewareSampleSupervisor {
518
772
  private async bootstrap(): Promise<void> {
519
773
  const { deps } = this;
520
774
  if (this.stopped) return;
775
+ // Retryable-vs-opt-out: a missing CLI, an absent token and a flaky listApps
776
+ // are all transient, but they used to `return`, which left the sample dormant
777
+ // for the whole process lifetime (nothing re-enters bootstrap on its own).
778
+ // Throw instead so startAttempt's START_RETRY_DELAYS_MS ladder gets its turn;
779
+ // only a genuine opt-out (disabled row, owner already has apps) returns.
521
780
  const livewarePath = deps.resolveLivewarePath();
522
- if (!livewarePath) { deps.log?.warn?.("liveware-sample: liveware CLI not ready; skip"); return; }
781
+ if (!livewarePath) throw new Error("liveware CLI not ready");
523
782
  const token = deps.resolveToken();
524
- if (!token) { deps.log?.debug?.("liveware-sample: no token; skip"); return; }
525
- let apps: { apps: { app_id?: string }[] };
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
- }
783
+ if (!token) throw new Error("no ClawChat token available yet");
784
+ const apps = await deps.listApps();
532
785
  if (this.bailIfStopped()) return;
533
786
  if (apps.apps.length > 0) {
534
787
  deps.log?.debug?.("liveware-sample: user already has liveware apps; skip");
@@ -546,9 +799,30 @@ export class LivewareSampleSupervisor {
546
799
  this.serverChild = server.child;
547
800
  if (this.bailIfStopped()) return;
548
801
  await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
549
- const appId = await livewareAppCreate({
802
+ // Reuse an app we already own before minting another one. `app create` is not
803
+ // idempotent and nothing here ever reconciled against the liveware side, so
804
+ // every bootstrap that died past this point (no row was written until the very
805
+ // end, see below) left one more orphan app behind and ate into the account's
806
+ // app quota. Best-effort: an unreadable list falls through to `app create`.
807
+ const existingAppId = await livewareAppFindByName({
808
+ livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn, log: deps.log,
809
+ });
810
+ if (this.bailIfStopped()) return;
811
+ if (existingAppId) {
812
+ deps.log?.debug?.(`liveware-sample: reusing existing liveware app ${existingAppId}`);
813
+ }
814
+ const appId = existingAppId ?? await livewareAppCreate({
550
815
  livewarePath, name: LIVEWARE_SAMPLE_APP_NAME, execFileFn: deps.execFileFn,
551
816
  });
817
+ // Persist the app id BEFORE the steps that can still fail. Without this a
818
+ // failure anywhere below left no row at all, so the next boot ran bootstrap
819
+ // again and created yet another app; a "pending" row makes the next attempt
820
+ // resume through relaunch() with the same app id instead.
821
+ deps.store.upsertLivewareSample({
822
+ platform: deps.platform, accountId: deps.accountId,
823
+ appId, appName: LIVEWARE_SAMPLE_APP_NAME, port: server.port,
824
+ publicUrl: null, sampleVersion: version, status: "pending",
825
+ });
552
826
  const publicUrl = await tunnelBind({
553
827
  livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
554
828
  });
@@ -570,20 +844,22 @@ export class LivewareSampleSupervisor {
570
844
  private async relaunch(row: LivewareSampleRow): Promise<void> {
571
845
  const { deps } = this;
572
846
  if (this.stopped) return;
847
+ // Both of these used to `return` and strand the sample until the next process
848
+ // start; they are transient, so throw and let the retry ladder run (see
849
+ // bootstrap). Only a successful list that no longer contains the app is an
850
+ // opt-out.
573
851
  const livewarePath = deps.resolveLivewarePath();
574
- if (!livewarePath) { deps.log?.warn?.("liveware-sample: liveware CLI not ready; skip relaunch"); return; }
852
+ if (!livewarePath) throw new Error("liveware CLI not ready");
853
+ const apps = await deps.listApps();
575
854
  // The user deleting the app tile is an explicit opt-out — never reinstall.
576
- try {
577
- const apps = await deps.listApps();
578
- if (!apps.apps.some((a) => a.app_id === row.app_id)) {
579
- deps.store.updateLivewareSampleStatus({
580
- platform: deps.platform, accountId: deps.accountId, status: "disabled",
581
- lastError: "app removed by user",
582
- });
583
- return;
584
- }
585
- } catch (err) {
586
- deps.log?.debug?.(`liveware-sample: listApps failed; skip relaunch: ${String(err)}`);
855
+ // Exception: a "pending" row was persisted mid-bootstrap, before the app was
856
+ // ever registered with ClawChat, so its absence from this list means "not
857
+ // registered yet", not "deleted" — disabling it there would strand the app id.
858
+ if (row.status !== "pending" && !apps.apps.some((a) => a.app_id === row.app_id)) {
859
+ deps.store.updateLivewareSampleStatus({
860
+ platform: deps.platform, accountId: deps.accountId, status: "disabled",
861
+ lastError: "app removed by user",
862
+ });
587
863
  return;
588
864
  }
589
865
  if (this.bailIfStopped()) return;
@@ -607,21 +883,59 @@ export class LivewareSampleSupervisor {
607
883
  });
608
884
  this.serverChild = server.child;
609
885
  if (this.bailIfStopped()) return;
886
+ // Re-login before touching the CLI. Its credentials live in $HOME/.clawling,
887
+ // a different volume from the stateDir holding the row that sent us down this
888
+ // path: a container that persists stateDir but not $HOME comes back with a row
889
+ // AND a logged-out CLI, so every tunnelBind/agent below fails auth until the
890
+ // retry ladder gives up — the sample then never returns for the whole process
891
+ // lifetime. `login` is idempotent; best-effort, so a stale token still falls
892
+ // through to whatever credentials the CLI already has.
893
+ const token = deps.resolveToken();
894
+ if (token) {
895
+ try {
896
+ await livewareLogin({ livewarePath, token, execFileFn: deps.execFileFn });
897
+ } catch (err) {
898
+ deps.log?.warn?.(
899
+ `liveware-sample: relaunch re-login failed; continuing with cached CLI credentials: ${String(err)}`,
900
+ );
901
+ }
902
+ if (this.bailIfStopped()) return;
903
+ } else {
904
+ deps.log?.debug?.("liveware-sample: no token; relaunch without re-login");
905
+ }
906
+ // Kept after the re-login above so the lookup runs against a CLI that is
907
+ // actually authenticated: a pending row's app id came straight from
908
+ // `app create`'s parser and was never confirmed by the liveware side, so
909
+ // prefer a by-name lookup — a mis-parsed id then self-heals instead of
910
+ // failing every tunnel bind for the rest of this row's life.
911
+ let appId = row.app_id;
912
+ if (row.status === "pending") {
913
+ const found = await livewareAppFindByName({
914
+ livewarePath, name: row.app_name, execFileFn: deps.execFileFn, log: deps.log,
915
+ });
916
+ if (found && found !== appId) {
917
+ deps.log?.warn?.(`liveware-sample: pending app id ${appId} not listed; adopting ${found}`);
918
+ appId = found;
919
+ }
920
+ if (this.bailIfStopped()) return;
921
+ }
610
922
  const publicUrl = await tunnelBind({
611
- livewarePath, appId: row.app_id, port: server.port, execFileFn: deps.execFileFn,
923
+ livewarePath, appId, port: server.port, execFileFn: deps.execFileFn,
612
924
  });
613
925
  if (this.bailIfStopped()) return;
614
926
  const agent = await startTunnelAgent({ livewarePath, spawnFn: deps.spawnFn });
615
927
  this.tunnelChild = agent.child;
616
928
  if (this.bailIfStopped()) return;
617
- if (publicUrl !== row.public_url) {
929
+ // A pending row has never been registered with ClawChat (public_url is null),
930
+ // so this condition also covers "register it for the first time".
931
+ if (publicUrl !== row.public_url || appId !== row.app_id) {
618
932
  await deps.registerApp({
619
- name: row.app_name, appId: row.app_id, url: publicUrl,
933
+ name: row.app_name, appId, url: publicUrl,
620
934
  });
621
935
  }
622
936
  deps.store.upsertLivewareSample({
623
937
  platform: deps.platform, accountId: deps.accountId,
624
- appId: row.app_id, appName: row.app_name, port: server.port,
938
+ appId, appName: row.app_name, port: server.port,
625
939
  publicUrl, sampleVersion: version, status: "active",
626
940
  });
627
941
  this.watchChildren();
@@ -652,16 +966,25 @@ export class LivewareSampleSupervisor {
652
966
  platform: this.deps.platform, accountId: this.deps.accountId,
653
967
  });
654
968
  if (!row || row.status === "disabled") return;
655
- void this.relaunch(row).catch((err) => {
656
- // A partially-completed relaunch may have spawned children before
657
- // failing — never leave them running unwatched.
658
- this.killChildren();
659
- this.deps.log?.warn?.(`liveware-sample: relaunch failed: ${String(err)}`);
660
- this.deps.store.updateLivewareSampleStatus({
661
- platform: this.deps.platform, accountId: this.deps.accountId,
662
- status: "failed", lastError: String(err),
663
- });
664
- });
969
+ // This relaunch does NOT go through startAttempt, so it must do that
970
+ // method's launchInFlight bookkeeping itself: the timer has already been
971
+ // dropped from `timers` and children are only assigned a few awaits in, so
972
+ // without this isIdle() reports idle for the whole early part of a
973
+ // crash-restart and a concurrent adoptDeps kick would run a SECOND launch
974
+ // flow — the duplicate registration the single-supervisor rule prevents.
975
+ this.launchInFlight = true;
976
+ void this.relaunch(row)
977
+ .catch((err) => {
978
+ // A partially-completed relaunch may have spawned children before
979
+ // failing — never leave them running unwatched.
980
+ this.killChildren();
981
+ this.deps.log?.warn?.(`liveware-sample: relaunch failed: ${String(err)}`);
982
+ this.deps.store.updateLivewareSampleStatus({
983
+ platform: this.deps.platform, accountId: this.deps.accountId,
984
+ status: "failed", lastError: String(err),
985
+ });
986
+ })
987
+ .finally(() => { this.launchInFlight = false; });
665
988
  }, delay);
666
989
  };
667
990
  for (const child of [this.serverChild, this.tunnelChild]) {