cicy-desktop 2.1.179 → 2.1.181

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cicy-desktop",
3
- "version": "2.1.179",
3
+ "version": "2.1.181",
4
4
  "description": "CiCy - AI-powered operating system browser",
5
5
  "main": "src/main.js",
6
6
  "bin": {
@@ -340,13 +340,16 @@ function register({ sidecarLogPath } = {}) {
340
340
 
341
341
  // ⋯ menu → 重建 Docker:删容器 + 用新 env(docker team 网关 key)重新 docker run(保留
342
342
  // volume 数据)。换 key 的唯一途径。渲染端已 confirm。
343
- ipcMain.handle("docker:app-recreate", async () => {
343
+ ipcMain.handle("docker:app-recreate", async (e) => {
344
344
  try {
345
345
  await ensureDockerTeam();
346
- await appDocker.recreate({ ...(await appOpts()) });
346
+ await appDocker.recreate({
347
+ ...(await appOpts()),
348
+ onProgress: (ev) => { try { e.sender.send("docker:app-progress", ev); } catch {} },
349
+ });
347
350
  await registerAppTeam();
348
351
  return { ok: true };
349
- } catch (e) { return { ok: false, error: e.message }; }
352
+ } catch (err) { return { ok: false, error: err.message }; }
350
353
  });
351
354
 
352
355
  // ⋯ menu(仅 macOS)→ 「授权容器访问 Mac」:不挂 docker,改走 SSH。colima 自带
package/src/main.js CHANGED
@@ -1377,25 +1377,38 @@ function cleanup() {
1377
1377
  log.info("[Cleanup] shutting down child services");
1378
1378
  try { cicyCodeSidecar.stop(); } catch (e) { /* best-effort */ }
1379
1379
 
1380
- // Kill cicy-code, all tmux servers, gotty/ttyd, and code-server processes
1381
- // that the cicy-code sidecar may have spawned. best-effort, sync, cross-platform.
1380
+ // Reap leftover NATIVE host helpers from the pre-docker-only era (a stray
1381
+ // cicy-code.exe / ttyd / gotty / code-server). 主人: quitting CiCy Desktop must
1382
+ // NOT touch Docker — in docker-only these all run INSIDE the container, and the
1383
+ // Docker engine (colima VM on mac / WSL distro+dockerd on win) is a separate
1384
+ // background service that survives the app + the container is --restart
1385
+ // unless-stopped. So we ONLY kill exact native host targets, never the engine.
1382
1386
  try {
1383
1387
  const { execSync } = require("child_process");
1384
1388
  const targets = ["cicy-code", "ttyd", "gotty", "code-server"];
1385
1389
 
1386
1390
  if (process.platform === "win32") {
1387
- // Windows: taskkill /F /IM <name>.exe (and base name in case extension differs)
1391
+ // Windows: taskkill /F /IM <name>.exe EXACT image name, no wildcards, so it
1392
+ // can only hit a native cicy-code.exe (retired), never wsl.exe / dockerd /
1393
+ // vmmem / the WSL distro. We never `wsl --terminate/--shutdown` on quit.
1388
1394
  for (const t of targets) {
1389
1395
  try { execSync(`taskkill /F /IM ${t}.exe`, { stdio: "ignore", windowsHide: true }); } catch {}
1390
- try { execSync(`taskkill /F /IM ${t}`, { stdio: "ignore", windowsHide: true }); } catch {}
1391
1396
  }
1392
- // Windows has no tmux by default; nothing to do.
1393
1397
  } else {
1394
- // macOS / Linux: pkill matches by command line.
1395
- for (const t of targets) {
1396
- try { execSync(`pkill -f ${t}`, { stdio: "ignore" }); } catch {}
1398
+ // macOS / Linux: pkill matches by FULL command line. ⚠️ docker-only: cicy-code
1399
+ // / ttyd / gotty / code-server all run INSIDE the container, NOT on the host —
1400
+ // there is normally nothing here to kill. CRITICAL: never `pkill -f cicy-code`
1401
+ // plainly — the colima profile is named `cicy-code`, so the Lima VM hostagent
1402
+ // runs as `limactl ... colima-cicy-code` and a bare match KILLS THE VM on every
1403
+ // quit → next launch sees Docker "down" and re-bootstraps ("重新 install" bug).
1404
+ // Anchor cicy-code to the native --desktop sidecar form (retired) only.
1405
+ const unixPats = ["cicy-code --desktop", "ttyd", "gotty", "code-server"];
1406
+ for (const p of unixPats) {
1407
+ try { execSync(`pkill -f '${p}'`, { stdio: "ignore" }); } catch {}
1397
1408
  }
1398
- try { execSync(`tmux kill-server`, { stdio: "ignore" }); } catch {}
1409
+ // Host tmux only (native path, retired); the container's tmux is in its own
1410
+ // namespace and unaffected. (No -f cicy-code match here either.)
1411
+ try { execSync(`pkill -f 'tmux.*cicy'`, { stdio: "ignore" }); } catch {}
1399
1412
  }
1400
1413
  } catch (e) {
1401
1414
  log.warn(`[Cleanup] kill children failed: ${e.message}`);
@@ -37,6 +37,12 @@ const IMAGE = process.env.CICY_DOCKER_IMAGE || "cicybot/cicy-code:latest";
37
37
  const IS_ARM = process.arch === "arm64";
38
38
  const ARCH_TAG = IS_ARM ? "arm64" : "amd64";
39
39
  const PLATFORM_FLAG = IS_ARM ? "--platform linux/amd64" : "";
40
+ // 主人: 容器额外暴露的端口段(给容器内 agent 跑服务用),宿主 127.0.0.1 直达。
41
+ const EXTRA_PORTS = process.env.CICY_EXTRA_PORTS || "18000-19999";
42
+ // 主人: Colima VM 资源,4C8G 起步(可用 env 覆盖,不写死)。
43
+ const VM_CPUS = process.env.CICY_COLIMA_CPUS || "4";
44
+ const VM_MEMORY = process.env.CICY_COLIMA_MEMORY || "8";
45
+ const VM_DISK = process.env.CICY_COLIMA_DISK || "30";
40
46
 
41
47
  // Colima 基础 VM 镜像:colima `start` 默认从 github.com/abiosoft/colima-core 下,
42
48
  // CN 直接 EOF 拉不下来(真机实测)。colima 0.10.3 没有 --disk-image-mirror(那是
@@ -222,7 +228,7 @@ async function startVM({ emit } = {}) {
222
228
  const vzArgs = IS_ARM
223
229
  ? `--vm-type vz --vz-rosetta --mount-type virtiofs`
224
230
  : `--vm-type vz --mount-type virtiofs`;
225
- const common = `--cpus 2 --memory 4 --disk 30 ${diskArgs}`;
231
+ const common = `--cpus ${VM_CPUS} --memory ${VM_MEMORY} --disk ${VM_DISK} ${diskArgs}`;
226
232
  for (let attempt = 1; attempt <= 3; attempt++) {
227
233
  emit && emit({ phase: "container", status: "running", message: `启动 Colima 运行环境(首次较慢,请耐心)…${attempt > 1 ? `(重试 ${attempt})` : ""}` });
228
234
  try {
@@ -263,6 +269,28 @@ async function loadImage(tarball, { emit } = {}) {
263
269
  if (m && m[1] !== IMAGE) { try { await dk(`tag ${m[1]} ${IMAGE}`, { timeout: 15000 }); } catch {} }
264
270
  }
265
271
 
272
+ // 主人修复「为什么没用最新的 docker」: imagePresent() 只看本地有没有 `:latest` 标签,
273
+ // 旧镜像在就永远跳过下载 → 卡在过期镜像。这里改成校验 OSS tarball 的 ETag:本地缺镜像、
274
+ // 或 OSS 的 ETag 跟上次 load 的不一致 → 重下重载(删旧缓存包,避免同尺寸被误跳过)。
275
+ // 非破坏性:不删 VM、不删 volume(数据/token 不丢)。返回是否真的刷新了。
276
+ async function ensureFreshImage({ emit } = {}) {
277
+ const e = (ev) => { try { emit && emit(ev); } catch {} };
278
+ const present = await imagePresent();
279
+ const remote = await docker.remoteImageEtag().catch(() => "");
280
+ const loaded = docker.readLoadedImageEtag();
281
+ // 本地有镜像、且能证明是最新(ETag 一致)→ 不动。HEAD 失败(remote 空)时不强刷,
282
+ // 避免离线/被墙时把能用的旧镜像也拖垮。
283
+ if (present && remote && loaded && remote === loaded) return false;
284
+ if (present && !remote) return false; // 拿不到远端指纹,保守不刷
285
+ if (present && remote !== loaded) { e({ phase: "image", status: "running", message: "检测到更新的镜像,正在拉取最新版…" }); docker.clearImageTarball(); }
286
+ let tarball;
287
+ try { tarball = await docker.downloadImageTarball({ emit }); }
288
+ catch (err) { if (present) { e({ phase: "image", status: "running", message: `最新镜像下载失败(${err.message}),沿用现有镜像` }); return false; } throw err; }
289
+ await loadImage(tarball, { emit });
290
+ if (remote) docker.writeLoadedImageEtag(remote);
291
+ return true;
292
+ }
293
+
266
294
  // 宿主 127.0.0.1:port 健康探针 —— Lima 自动把 VM 内 127.0.0.1 端口转发到宿主。
267
295
  const probeHealth = docker.probeHealth;
268
296
 
@@ -277,44 +305,41 @@ const probeHealth = docker.probeHealth;
277
305
  // bind-mount 会用空的宿主目录**遮住镜像里预装的 /home/cicy**(cicy-code 装在那),
278
306
  // entrypoint 找不到就试图全局 npm 重装 → EACCES 崩溃,:8009 起不来。named volume
279
307
  // 首次挂载会**从镜像内容预填充**,容器才看得到预装的 cicy-code(和 WSL 一致)。
280
- // Shared folder bind: the CURRENT macOS user's ~/Desktop/Share ↔
281
- // /home/cicy/cicy-ai/Share in the container. Auto-created (with a readme) on every
282
- // container start if missing; os.homedir() makes it per-user. Colima mounts the
283
- // host $HOME into the VM, so the direct path works (no /mnt translation like WSL).
284
- const SHARE_README = `# CiCy 共享目录 / Shared Folder
308
+ // 主人: 把宿主 ~/projects 挂进容器 /home/cicy/projects(~ os.homedir() 展开,绝不写死)。
309
+ // Colima 把宿主 $HOME 挂进 VM,所以直接路径就能用(不像 WSL /mnt 转换)。
310
+ // 源目录不存在先建出来,否则 docker 建挂载点会报错挡住容器启动。
311
+ const PROJECTS_README = `# CiCy 持久工作区 / Persistent Workspace
285
312
 
286
- 这个文件夹与 CiCy 容器之间双向共享 —— 你和容器里的 agent 都能读写同一份文件。
313
+ 这个文件夹与 CiCy 容器双向共享,而且是**持久的** —— 它在你电脑的真实磁盘上,
314
+ 不在容器里。容器删了、重建了、升级了,这里的文件都还在,不会丢。
287
315
 
288
- - 你的电脑上: ~/Desktop/Share (就是这个文件夹)
289
- - 容器里: /home/cicy/cicy-ai/Share
316
+ - 你的电脑上: ~/projects (就是这个文件夹)
317
+ - 容器里: /home/cicy/projects
290
318
 
291
- 把文件丢进来,CiCy 里的 agent 就能访问;agent 写到容器 Share 里的东西,也会出现在这里。
319
+ 把代码仓库 / 项目放在这里,CiCy 里的 agent 就能直接读写;它们改的东西也实时
320
+ 出现在你电脑上。容器是临时的,这个目录才是你工作的家。
292
321
 
293
322
  ---
294
323
 
295
- This folder is shared both ways between your computer and the CiCy container.
324
+ This folder is shared both ways with the CiCy container and is **persistent**
325
+ it lives on your computer's real disk, not inside the container. Delete, rebuild
326
+ or upgrade the container and everything here survives.
296
327
 
297
- - On your computer: ~/Desktop/Share (this folder)
298
- - Inside the container: /home/cicy/cicy-ai/Share
328
+ - On your computer: ~/projects (this folder)
329
+ - Inside the container: /home/cicy/projects
299
330
 
300
- Drop files here for CiCy agents to read; files agents write to Share show up here too.
331
+ Put your repos / projects here; CiCy agents read and write them directly, and
332
+ their changes show up live on your machine. The container is disposable — this
333
+ directory is where your work actually lives.
301
334
  `;
302
335
 
303
- function shareMountArg() {
336
+ function projectsMountArg() {
304
337
  try {
305
- // 主人: Share 真实目录放 ~/cicy-ai/Share —— 不能用 ~/Desktop:macOS TCC 挡着 colima(VM)
306
- // 访问 Desktop/Documents/Downloads,docker 建挂载源时报 'mkdir …/Desktop: operation not
307
- // permitted' 容器起不来。~/cicy-ai 非 TCC、colima 能挂。再在 Desktop 放个软链(桌面可见;
308
- // 失败无所谓,真实目录已可用)
309
- const share = path.join(os.homedir(), "cicy-ai", "Share");
310
- fs.mkdirSync(share, { recursive: true });
311
- const readme = path.join(share, "readme.md");
312
- if (!fs.existsSync(readme)) { try { fs.writeFileSync(readme, SHARE_README); } catch {} }
313
- try {
314
- const link = path.join(os.homedir(), "Desktop", "Share");
315
- if (!fs.existsSync(link)) fs.symlinkSync(share, link);
316
- } catch {}
317
- return `-v '${share}':/home/cicy/cicy-ai/Share`;
338
+ const dir = path.join(os.homedir(), "projects");
339
+ fs.mkdirSync(dir, { recursive: true });
340
+ const readme = path.join(dir, "README.md");
341
+ if (!fs.existsSync(readme)) { try { fs.writeFileSync(readme, PROJECTS_README); } catch {} }
342
+ return `-v '${dir}':/home/cicy/projects`;
318
343
  } catch { return ""; }
319
344
  }
320
345
 
@@ -329,15 +354,16 @@ async function runContainer({ port = 8009, container = "cicy-code-docker-8009",
329
354
  // 服务,不再从容器 publish 20001-32 —— colima/Lima 转发那个端口段始终到不了容器里只绑
330
355
  // 127.0.0.1 的监听(Chrome 一直 ERR_EMPTY_RESPONSE)。容器只暴露 cicy-code 的 :8009。
331
356
  const mk = (s) => `run -d --name ${container} --restart unless-stopped ${PLATFORM_FLAG} ` +
332
- `-p 127.0.0.1:${port}:8008 -e CICY_PUBLIC=1 -v ${volume}:/home/cicy ${s} ${envArgs} ${IMAGE}`;
333
- const share = shareMountArg();
357
+ `-p 127.0.0.1:${port}:8008 -p 127.0.0.1:${EXTRA_PORTS}:${EXTRA_PORTS} ` +
358
+ `-e CICY_PUBLIC=1 -v ${volume}:/home/cicy ${s} ${envArgs} ${IMAGE}`;
359
+ const mounts = projectsMountArg();
334
360
  try {
335
- await dk(mk(share), { timeout: 90000 });
361
+ await dk(mk(mounts), { timeout: 90000 });
336
362
  } catch (e) {
337
- if (!share) throw e;
338
- // 兜底: 带 Share 启动失败(如 macOS TCC 挡 colima 访问挂载源)→ 去掉 Share 重试,保证容器
339
- // 一定能起(Share 只是附加共享目录,不该挡住整个服务). 主人: '容器起不来' 的修复.
340
- console.warn(`[colima] Share 启动失败,去掉 Share 重试: ${e.message}`);
363
+ if (!mounts) throw e;
364
+ // 兜底: 带挂载启动失败(如 colima 访问挂载源出错)→ 去掉附加挂载重试,保证容器
365
+ // 一定能起(projects 只是附加共享目录,不该挡住整个服务). 主人: '容器起不来' 的修复.
366
+ console.warn(`[colima] 带挂载启动失败,去掉附加挂载重试: ${e.message}`);
341
367
  try { await dk(`rm -f ${container}`, { timeout: 20000 }); } catch {}
342
368
  await dk(mk(""), { timeout: 90000 });
343
369
  }
@@ -445,13 +471,11 @@ async function _bootstrap({ onProgress, port = 8009, container = "cicy-code-dock
445
471
  }
446
472
  }
447
473
 
448
- // 4) 镜像(docker load R2 包)
449
- if (!(await imagePresent())) {
450
- let tarball;
451
- try { tarball = await docker.downloadImageTarball({ emit }); }
452
- catch (e) { emit({ phase: "image", status: "error", message: `镜像下载失败:${e.message}(点重试续传)` }); return { ok: false, reason: "image_download_failed" }; }
453
- try { await loadImage(tarball, { emit }); }
454
- catch (e) { emit({ phase: "image", status: "error", message: `镜像导入失败:${e.message}(点重试)` }); return { ok: false, reason: "image_load_failed" }; }
474
+ // 4) 镜像(docker load R2 包)—— 缺镜像就下,镜像在但 OSS 有更新版也刷新(主人:别卡旧镜像)
475
+ try { await ensureFreshImage({ emit }); }
476
+ catch (e) {
477
+ if (!(await imagePresent())) { emit({ phase: "image", status: "error", message: `镜像下载失败:${e.message}(点重试续传)` }); return { ok: false, reason: "image_download_failed" }; }
478
+ emit({ phase: "image", status: "running", message: `镜像刷新失败(${e.message}),沿用现有镜像` });
455
479
  }
456
480
 
457
481
  // 5) 容器
@@ -507,7 +531,10 @@ async function dockerRestart({ container = "cicy-code-docker-8009" } = {}) {
507
531
 
508
532
  // 重建:强删占该端口的任何容器 + 目标容器,再 docker run(用新 env,如新 docker team
509
533
  // 网关 key)。保留 bind-mount 宿主目录(数据/api_token 不丢)。破坏性 → 调用方要 confirm。
510
- async function recreate({ port = 8009, container = "cicy-code-docker-8009", volume = "cicy-team-8009", env = {} } = {}) {
534
+ async function recreate({ onProgress, port = 8009, container = "cicy-code-docker-8009", volume = "cicy-team-8009", env = {} } = {}) {
535
+ const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
536
+ // 重建 = 用最新镜像重建。OSS 有更新版先刷新(非破坏性,不删 VM/volume),再 rm + run。
537
+ try { await ensureFreshImage({ emit }); } catch (e) { emit({ phase: "image", status: "running", message: `镜像刷新跳过(${e.message}),用现有镜像重建` }); }
511
538
  try { await dk(`ps -aq --filter publish=${port} | xargs -r docker --context ${CTX} rm -f 2>/dev/null; docker --context ${CTX} rm -f ${container} 2>/dev/null; true`, { timeout: 30000 }); } catch {}
512
539
  const r = await runContainer({ port, container, volume, env });
513
540
  try { await ensureDesktopShortcut(volume, port); } catch {}
@@ -181,6 +181,38 @@ function headSize(url, hops = 5) {
181
181
  });
182
182
  }
183
183
 
184
+ // HEAD the URL and return a freshness fingerprint (ETag, else Last-Modified).
185
+ // Mirrors headSize (follows redirects, node http so it works without curl).
186
+ // "" on any failure → callers treat unknown as "can't prove fresh".
187
+ function headETag(url, hops = 5) {
188
+ return new Promise((resolve) => {
189
+ if (hops <= 0) return resolve("");
190
+ const lib = url.startsWith("https:") ? https : http;
191
+ const req = lib.request(url, { method: "HEAD", timeout: 15000, headers: { "User-Agent": DL_UA } }, (res) => {
192
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
193
+ res.resume();
194
+ return headETag(res.headers.location, hops - 1).then(resolve);
195
+ }
196
+ resolve(String(res.headers["etag"] || res.headers["last-modified"] || "").trim());
197
+ });
198
+ req.on("error", () => resolve(""));
199
+ req.on("timeout", () => { req.destroy(); resolve(""); });
200
+ req.end();
201
+ });
202
+ }
203
+
204
+ // Marker recording the OSS fingerprint of the image tarball we last `docker load`ed.
205
+ // Lets recreate/bootstrap detect a refreshed OSS image (same `:latest` tag, new
206
+ // content) and re-pull — without it, `imagePresent()` stays true forever and the
207
+ // machine is pinned to a stale image (主人 bug: "为什么没用最新的docker").
208
+ function imageEtagPath() { return path.join(downloadsDir(), "cicy-code-latest.etag"); }
209
+ function readLoadedImageEtag() { try { return fs.readFileSync(imageEtagPath(), "utf8").trim(); } catch { return ""; } }
210
+ function writeLoadedImageEtag(v) { try { fs.writeFileSync(imageEtagPath(), String(v || "")); } catch {} }
211
+ async function remoteImageEtag() { return headETag(R2_TARBALL); }
212
+ // Drop the cached tarball so a stale copy on disk can't be reused (downloadImageTarball
213
+ // skips a complete file by size, which a same-size new image would wrongly satisfy).
214
+ function clearImageTarball() { try { fs.unlinkSync(imageTarballPath()); } catch {} }
215
+
184
216
  // Download `url`→`dest` but: SKIP if the file is already complete, RESUME if it's
185
217
  // a partial, retry with progress, fall back to `mirror`. This is the core of the
186
218
  // user's "下载了就不重复下载 / 步骤走过的不要再走".
@@ -735,4 +767,6 @@ module.exports = {
735
767
  launchElevated, wslMissing, ensureWsl,
736
768
  // platform-agnostic download/retry primitives, reused by native.js
737
769
  ensureDownloaded, curlDownload, withRetry, waitUntil, run, headSize,
770
+ // image freshness (主人: 修「重建仍用旧镜像」—— 校验 OSS ETag 变了才重下重载)
771
+ headETag, remoteImageEtag, readLoadedImageEtag, writeLoadedImageEtag, clearImageTarball,
738
772
  };
@@ -21,6 +21,8 @@ const log = require("electron-log"); // persisted main.log — bootstrap timing/
21
21
  // Dedicated distro name — NEVER reuse/clobber a user's own "Ubuntu" distro.
22
22
  const DISTRO = process.env.CICY_WSL_DISTRO || "cicy-code-wsl";
23
23
  const IMAGE = process.env.CICY_DOCKER_IMAGE || "cicybot/cicy-code:latest";
24
+ // 主人: 容器额外暴露的端口段(给容器内 agent 跑服务用),宿主 127.0.0.1 直达。
25
+ const EXTRA_PORTS = process.env.CICY_EXTRA_PORTS || "18000-19999";
24
26
  // PRE-BAKED WSL rootfs (built in CI, .github/workflows/build-wsl-package.yml):
25
27
  // Ubuntu 22.04 + Docker Engine + the cicy-code image already loaded into
26
28
  // /var/lib/docker, with dockerd auto-start via /etc/wsl.conf. We just download
@@ -360,6 +362,25 @@ async function loadImage(winTarballPath, { emit } = {}) {
360
362
  if (m && m[1] !== IMAGE) { try { await wslRun(`docker tag ${m[1]} ${IMAGE}`, { timeout: 15000 }); } catch {} }
361
363
  }
362
364
 
365
+ // 主人修复「卡在旧镜像」: imagePresent() 只看本地有没有 `:latest`,旧镜像在就永远跳过下载。
366
+ // 改成校验 OSS tarball ETag:缺镜像、或 ETag 跟上次 load 不一致 → 重下重载(删旧缓存包)。
367
+ // 非破坏性:不删发行版/不删 volume。返回是否真刷新了。与 colima 端逻辑一致。
368
+ async function ensureFreshImage({ emit } = {}) {
369
+ const e = (ev) => { try { emit && emit(ev); } catch {} };
370
+ const present = await imagePresent();
371
+ const remote = await docker.remoteImageEtag().catch(() => "");
372
+ const loaded = docker.readLoadedImageEtag();
373
+ if (present && remote && loaded && remote === loaded) return false;
374
+ if (present && !remote) return false; // 拿不到远端指纹,保守不刷
375
+ if (present && remote !== loaded) { e({ phase: "image", status: "running", message: "检测到更新的镜像,正在拉取最新版…" }); docker.clearImageTarball(); }
376
+ let tarball;
377
+ try { tarball = await docker.downloadImageTarball({ emit }); }
378
+ catch (err) { if (present) { e({ phase: "image", status: "running", message: `最新镜像下载失败(${err.message}),沿用现有镜像` }); return false; } throw err; }
379
+ await loadImage(tarball, { emit });
380
+ if (remote) docker.writeLoadedImageEtag(remote);
381
+ return true;
382
+ }
383
+
363
384
  // HTTP /health probe on 127.0.0.1:port from Windows — WSL2 forwards localhost,
364
385
  // so a container published on :port is reachable here. Reuse docker.js's probe.
365
386
  const probeHealth = docker.probeHealth;
@@ -375,40 +396,47 @@ const probeHealth = docker.probeHealth;
375
396
  // network-exposed; the api_token gates access. WSL2's localhost relay then
376
397
  // forwards the distro's 127.0.0.1:<port> to Windows 127.0.0.1:<port>.
377
398
  // Only :<port> is published — sshd/cron stay inside the container's own netns.
378
- // Shared folder bind: the CURRENT Windows user's ~/Desktop/Share
379
- // /home/cicy/cicy-ai/Share in the container — a drop-zone both the user and the
380
- // agent can read/write. Auto-created (with a readme) on every container start if
381
- // missing; os.homedir() makes it per-user (never hard-coded to one account).
382
- // Returns the docker `-v` arg, or "" if setup fails (mount is best-effort).
383
- const SHARE_README = `# CiCy 共享目录 / Shared Folder
399
+ // Persistent workspace bind: the CURRENT Windows user's ~/projects
400
+ // /home/cicy/projects in the container — both the user and the agent read/write
401
+ // it, and it lives on the host disk so it survives container delete/rebuild.
402
+ // Auto-created (with a README) on every container start if missing; os.homedir()
403
+ // makes it per-user (never hard-coded). Returns the `-v` arg, or "" on failure.
404
+ const PROJECTS_README = `# CiCy 持久工作区 / Persistent Workspace
384
405
 
385
- 这个文件夹与 CiCy 容器之间双向共享 —— 你和容器里的 agent 都能读写同一份文件。
406
+ 这个文件夹与 CiCy 容器双向共享,而且是**持久的** —— 它在你电脑的真实磁盘上,
407
+ 不在容器里。容器删了、重建了、升级了,这里的文件都还在,不会丢。
386
408
 
387
- - 你的电脑上: ~/Desktop/Share (就是这个文件夹)
388
- - 容器里: /home/cicy/cicy-ai/Share
409
+ - 你的电脑上: ~/projects (就是这个文件夹)
410
+ - 容器里: /home/cicy/projects
389
411
 
390
- 把文件丢进来,CiCy 里的 agent 就能访问;agent 写到容器 Share 里的东西,也会出现在这里。
412
+ 把代码仓库 / 项目放在这里,CiCy 里的 agent 就能直接读写;它们改的东西也实时
413
+ 出现在你电脑上。容器是临时的,这个目录才是你工作的家。
391
414
 
392
415
  ---
393
416
 
394
- This folder is shared both ways between your computer and the CiCy container.
417
+ This folder is shared both ways with the CiCy container and is **persistent**
418
+ it lives on your computer's real disk, not inside the container. Delete, rebuild
419
+ or upgrade the container and everything here survives.
395
420
 
396
- - On your computer: ~/Desktop/Share (this folder)
397
- - Inside the container: /home/cicy/cicy-ai/Share
421
+ - On your computer: ~/projects (this folder)
422
+ - Inside the container: /home/cicy/projects
398
423
 
399
- Drop files here for CiCy agents to read; files agents write to Share show up here too.
424
+ Put your repos / projects here; CiCy agents read and write them directly, and
425
+ their changes show up live on your machine. The container is disposable — this
426
+ directory is where your work actually lives.
400
427
  `;
401
428
 
402
- function shareMountArg() {
429
+ // 主人: 把宿主 ~/projects 挂进容器 /home/cicy/projects(~ 用 os.homedir() 展开,绝不写死)。
430
+ // Windows 路径 C:\Users\<user>\projects → WSL 视图 /mnt/c/Users/<user>/projects。
431
+ function projectsMountArg() {
403
432
  try {
404
- const winShare = path.join(os.homedir(), "Desktop", "Share");
405
- fs.mkdirSync(winShare, { recursive: true });
406
- const readme = path.join(winShare, "readme.md");
407
- if (!fs.existsSync(readme)) { try { fs.writeFileSync(readme, SHARE_README); } catch {} }
408
- // C:\Users\<user>\Desktop\Share /mnt/c/Users/<user>/Desktop/Share (WSL view)
409
- const wslShare = winShare.replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`).replace(/\\/g, "/");
410
- return `-v '${wslShare}':/home/cicy/cicy-ai/Share`;
411
- } catch (e) { log.warn(`[wsl-docker] Share mount setup failed: ${e.message}`); return ""; }
433
+ const winProjects = path.join(os.homedir(), "projects");
434
+ fs.mkdirSync(winProjects, { recursive: true });
435
+ const readme = path.join(winProjects, "README.md");
436
+ if (!fs.existsSync(readme)) { try { fs.writeFileSync(readme, PROJECTS_README); } catch {} }
437
+ const wslProjects = winProjects.replace(/^([A-Za-z]):/, (_, d) => `/mnt/${d.toLowerCase()}`).replace(/\\/g, "/");
438
+ return `-v '${wslProjects}':/home/cicy/projects`;
439
+ } catch (e) { log.warn(`[wsl-docker] projects mount setup failed: ${e.message}`); return ""; }
412
440
  }
413
441
 
414
442
  async function runContainer({ port = 8009, container = "cicy-code-docker", volume = "cicy-team-8009", env = {} } = {}) {
@@ -427,7 +455,7 @@ async function runContainer({ port = 8009, container = "cicy-code-docker", volum
427
455
  // 223.5.5.5 (CN-fast) first, Google 8.8.8.8 as the overseas fallback.
428
456
  // 主人方案: Chrome 的 per-profile 代理改由「宿主 mihomo」(host-mihomo.js)服务,不再从容器
429
457
  // publish 20001-32(WSL/colima 转发那段口到不了容器里只绑 127.0.0.1 的监听)。容器只暴露 :8009。
430
- const cmd = `docker run -d --name ${container} --restart unless-stopped --dns 223.5.5.5 --dns 8.8.8.8 -p 127.0.0.1:${port}:8008 -e CICY_PUBLIC=1 -v ${volume}:/home/cicy ${shareMountArg()} ${envArgs} ${IMAGE}`;
458
+ const cmd = `docker run -d --name ${container} --restart unless-stopped --dns 223.5.5.5 --dns 8.8.8.8 -p 127.0.0.1:${port}:8008 -p 127.0.0.1:${EXTRA_PORTS}:${EXTRA_PORTS} -e CICY_PUBLIC=1 -v ${volume}:/home/cicy ${projectsMountArg()} ${envArgs} ${IMAGE}`;
431
459
  await wslRun(cmd, { timeout: 60000 });
432
460
  ensureDesktopShortcut(volume, port).catch(() => {});
433
461
  return { started: true };
@@ -616,17 +644,14 @@ async function _bootstrap({ onProgress, port = 8009, container = "cicy-code-dock
616
644
  done();
617
645
  } else done(true);
618
646
 
619
- // 5) Base image — pre-baked into the package, so this normally just confirms.
620
- // The download-tarball path is a fallback for a non-pre-baked rootfs.
647
+ // 5) Base image — pre-baked into the package, normally just confirms; but if the
648
+ // OSS tarball has a newer build (ETag changed) it refreshes (主人:别卡旧镜像)。
621
649
  begin("ensure-image");
622
- if (!(await imagePresent())) {
623
- let tarball;
624
- try { tarball = await docker.downloadImageTarball({ emit }); }
625
- catch (e) { fail("image_download_failed", e.message); emit({ phase: "image", status: "error", message: `镜像下载失败:${e.message}(点重试续传)` }); finish(false, "image_download_failed"); return { ok: false, reason: "image_download_failed" }; }
626
- try { await loadImage(tarball, { emit }); }
627
- catch (e) { fail("image_load_failed", e.message); emit({ phase: "image", status: "error", message: `镜像导入失败:${e.message}(点重试)` }); finish(false, "image_load_failed"); return { ok: false, reason: "image_load_failed" }; }
628
- done();
629
- } else done(true);
650
+ try { await ensureFreshImage({ emit }); done(); }
651
+ catch (e) {
652
+ if (!(await imagePresent())) { fail("image_download_failed", e.message); emit({ phase: "image", status: "error", message: `镜像下载失败:${e.message}(点重试续传)` }); finish(false, "image_download_failed"); return { ok: false, reason: "image_download_failed" }; }
653
+ emit({ phase: "image", status: "running", message: `镜像刷新失败(${e.message}),沿用现有镜像` }); done(true);
654
+ }
630
655
 
631
656
  // 6) Container (phase "container" = 启动服务)
632
657
  begin("run-container");
@@ -698,7 +723,10 @@ async function dockerRestart({ container = "cicy-code-docker-8009" } = {}) {
698
723
  // 重建容器:docker rm -f 旧容器 + docker run 新容器(用新 env,如新的 docker team 网关
699
724
  // key)。**保留 volume**(数据/api_token/deviceId 不丢),只是换掉容器本身 + env。
700
725
  // 破坏性(短暂中断 + 换 key)→ 调用方要 confirm。
701
- async function recreate({ port = 8009, container = "cicy-code-docker-8009", volume = "cicy-team-8009", env = {} } = {}) {
726
+ async function recreate({ onProgress, port = 8009, container = "cicy-code-docker-8009", volume = "cicy-team-8009", env = {} } = {}) {
727
+ const emit = (ev) => { try { onProgress && onProgress(ev); } catch {} };
728
+ // 重建 = 用最新镜像重建。OSS 有更新版先刷新(非破坏性,不删发行版/volume),再 rm + run。
729
+ try { await ensureFreshImage({ emit }); } catch (e) { emit({ phase: "image", status: "running", message: `镜像刷新跳过(${e.message}),用现有镜像重建` }); }
702
730
  // 强删占用该端口的**任何**容器(含老名字 cicy-code-docker)+ 目标容器 —— 否则
703
731
  // runContainer 开头的 probeHealth 看到旧容器还健康会 adopt 它、不重建,key 就换不了。
704
732
  try { await wslRun(`docker ps -aq --filter publish=${port} | xargs -r docker rm -f 2>/dev/null; docker rm -f ${container} 2>/dev/null; true`, { timeout: 30000 }); } catch {}