cicy-desktop 2.1.78 → 2.1.79

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.
Files changed (54) hide show
  1. package/bin/cicy-desktop +7 -7
  2. package/package.json +6 -6
  3. package/src/backends/homepage-preload.js +22 -0
  4. package/src/backends/homepage-react/assets/index-CKpaMBKz.css +1 -0
  5. package/src/backends/homepage-react/assets/index-CSsNZgC5.js +365 -0
  6. package/src/backends/homepage-react/index.html +2 -2
  7. package/src/backends/homepage-window.js +52 -7
  8. package/src/backends/ipc.js +57 -0
  9. package/src/backends/local-teams.js +73 -26
  10. package/src/backends/sidecar-ipc.js +11 -0
  11. package/src/backends/webview-preload.js +5 -3
  12. package/src/backends/window-manager.js +13 -3
  13. package/src/chrome/chrome-launcher.js +5 -4
  14. package/src/chrome/debugger-port-resolver.js +1 -1
  15. package/src/cloud/cloud-client.js +237 -41
  16. package/src/cluster/types.js +0 -5
  17. package/src/extension/inject.js +1 -1
  18. package/src/main.js +282 -88
  19. package/src/master/chrome-config.js +2 -2
  20. package/src/preload-rpc.js +1 -1
  21. package/src/profiles/profile-store.js +321 -0
  22. package/src/profiles/trusted-origins-store.js +95 -0
  23. package/src/server/worker-observability-routes.js +0 -2
  24. package/src/sidecar/cicy-code.js +84 -23
  25. package/src/sidecar/localbin.js +20 -3
  26. package/src/sidecar/native.js +3 -3
  27. package/src/sidecar/version.js +45 -0
  28. package/src/tabbrowser/newtab-protocol.js +54 -0
  29. package/src/tabbrowser/tab-browser.html +151 -0
  30. package/src/tabbrowser/tab-shell-preload.js +28 -0
  31. package/src/tabbrowser/tab-shell.html +227 -0
  32. package/src/tools/account-tools.js +191 -25
  33. package/src/tools/chrome-tools.js +173 -37
  34. package/src/tools/device-tools.js +25 -0
  35. package/src/tools/index.js +2 -0
  36. package/src/tools/tab-browser-tools.js +453 -0
  37. package/src/tools/window-tools.js +64 -7
  38. package/src/utils/brand-host-electron.js +25 -0
  39. package/src/utils/context-menu-options.js +80 -0
  40. package/src/utils/cookie-logins.js +58 -0
  41. package/src/utils/ip-probe.js +50 -0
  42. package/src/utils/rpc-audit.js +53 -0
  43. package/src/utils/rpc-guard.js +189 -0
  44. package/src/utils/window-monitor.js +5 -15
  45. package/src/utils/window-registry.js +210 -0
  46. package/src/utils/window-thumbnails.js +126 -0
  47. package/src/utils/window-utils.js +146 -109
  48. package/workers/render/package-lock.json +6 -6
  49. package/workers/render/src/App.css +36 -2
  50. package/workers/render/src/App.jsx +587 -103
  51. package/src/backends/artifact-ipc.js +0 -142
  52. package/src/backends/homepage-react/assets/index-DE9m6JTn.css +0 -1
  53. package/src/backends/homepage-react/assets/index-DLYMzgf5.js +0 -365
  54. package/src/cluster/artifact-registry.js +0 -61
@@ -0,0 +1,321 @@
1
+ // profile-store.js — the shared "browser profile" standard across both backends.
2
+ //
3
+ // One profile = one backend (Chrome OR Electron); the two keep their own store
4
+ // files and their own cookie engines, but expose an IDENTICAL core schema and
5
+ // the same operations (list / proxy / logins). This module is the single source
6
+ // of truth for that core — both src/tools/chrome-tools.js and
7
+ // src/tools/account-tools.js (and window-utils proxy auto-apply) route through
8
+ // it so the field names and semantics never drift.
9
+ //
10
+ // Stores (unchanged locations):
11
+ // chrome → ~/cicy-ai/db/chrome.json keyed "profile_<N>"
12
+ // electron → ~/data/electron/account-<N>.json
13
+ //
14
+ // Core fields (identical names in BOTH files, added lazily; missing = default):
15
+ // name : string
16
+ // proxy : { url, enabled } ← persisted desired proxy (normalized)
17
+ // logins : [ { platform, account, addedAt } ] ← one entry per platform
18
+ // note : string
19
+ // createdAt / updatedAt : ISO
20
+
21
+ const fs = require("fs");
22
+ const os = require("os");
23
+ const path = require("path");
24
+
25
+ const CHROME_JSON = path.join(os.homedir(), "cicy-ai", "db", "chrome.json");
26
+ const ELECTRON_DIR = path.join(os.homedir(), "data", "electron");
27
+
28
+ // ── proxy: the ONE normalizer ────────────────────────────────────────────────
29
+ // Accepts every historical encoding and returns the canonical {url, enabled}:
30
+ // ""/null/undefined → { url:"", enabled:false }
31
+ // "socks5://…" (string) → { url:s, enabled:!!s } (legacy chrome)
32
+ // { enable, url } → { url, enabled:!!enable } (legacy chrome obj)
33
+ // { enabled, url } → { url, enabled:!!enabled } (canonical)
34
+ function normalizeProxy(raw) {
35
+ if (raw == null || raw === "") return { url: "", enabled: false };
36
+ if (typeof raw === "string") return { url: raw, enabled: !!raw };
37
+ if (typeof raw === "object") {
38
+ const url = typeof raw.url === "string" ? raw.url : "";
39
+ const enabled = ("enabled" in raw ? !!raw.enabled : !!raw.enable) && !!url;
40
+ return { url, enabled };
41
+ }
42
+ return { url: "", enabled: false };
43
+ }
44
+
45
+ // proxyRules(p) → the string for Electron session.setProxy / Chromium --proxy-server
46
+ // (empty string = direct/no proxy).
47
+ function proxyRules(proxyLike) {
48
+ const p = normalizeProxy(proxyLike);
49
+ return p.enabled && p.url ? p.url : "";
50
+ }
51
+
52
+ // ── logins: shared mutation (one entry per platform, keyed case-insensitively) ─
53
+ function upsertLogin(logins, platform, account) {
54
+ const list = Array.isArray(logins) ? logins.slice() : [];
55
+ const key = String(platform || "").trim().toLowerCase();
56
+ if (!key) return list;
57
+ const next = list.filter((l) => String(l.platform || "").toLowerCase() !== key);
58
+ next.push({ platform: key, account: String(account || "").trim(), addedAt: new Date().toISOString() });
59
+ return next;
60
+ }
61
+
62
+ function removeLoginFrom(logins, platform) {
63
+ const list = Array.isArray(logins) ? logins : [];
64
+ const key = String(platform || "").trim().toLowerCase();
65
+ return list.filter((l) => String(l.platform || "").toLowerCase() !== key);
66
+ }
67
+
68
+ // ── rich login record (unified across chrome + electron) ─────────────────────
69
+ // One entry per site, keyed by `name` (platform/site name) case-insensitively.
70
+ // Legacy thin entries {platform, account, addedAt} are mapped forward so old
71
+ // data keeps working: platform→name, account→username, addedAt→loginAt.
72
+ const LOGIN_FIELDS = ["url", "name", "username", "email", "mobile", "twofa", "secondEmail", "note", "loginAt", "updatedAt"];
73
+
74
+ function normalizeLogin(raw) {
75
+ const r = raw && typeof raw === "object" ? raw : {};
76
+ const s = (v, alt = "") => (typeof v === "string" ? v : alt);
77
+ return {
78
+ url: s(r.url),
79
+ name: s(r.name, s(r.platform)),
80
+ username: s(r.username, s(r.account)),
81
+ email: s(r.email),
82
+ mobile: s(r.mobile),
83
+ twofa: s(r.twofa, s(r.totp)),
84
+ secondEmail: s(r.secondEmail),
85
+ note: s(r.note),
86
+ loginAt: s(r.loginAt, s(r.addedAt)),
87
+ updatedAt: s(r.updatedAt),
88
+ };
89
+ }
90
+
91
+ function loginKey(l) {
92
+ return String((l && (l.name || l.url)) || "").trim().toLowerCase();
93
+ }
94
+
95
+ // Upsert by site key; only NON-EMPTY incoming fields overwrite existing ones
96
+ // (so a partial patch never wipes data). Stamps loginAt (first seen) + updatedAt.
97
+ function upsertLoginRich(logins, login) {
98
+ const list = (Array.isArray(logins) ? logins : []).map(normalizeLogin);
99
+ const inc = normalizeLogin(login);
100
+ const key = loginKey(inc);
101
+ if (!key) return list;
102
+ const now = new Date().toISOString();
103
+ const i = list.findIndex((l) => loginKey(l) === key);
104
+ if (i >= 0) {
105
+ const merged = { ...list[i] };
106
+ for (const k of LOGIN_FIELDS) if (inc[k]) merged[k] = inc[k];
107
+ merged.loginAt = merged.loginAt || now;
108
+ merged.updatedAt = now;
109
+ list[i] = merged;
110
+ } else {
111
+ inc.loginAt = inc.loginAt || now;
112
+ inc.updatedAt = now;
113
+ list.push(inc);
114
+ }
115
+ return list;
116
+ }
117
+
118
+ function removeLoginRich(logins, key) {
119
+ const k = String(key || "").trim().toLowerCase();
120
+ return (Array.isArray(logins) ? logins : []).map(normalizeLogin).filter((l) => loginKey(l) !== k);
121
+ }
122
+
123
+ // ── ipInfo: per-profile egress IP + geo area + last-probed time ──────────────
124
+ function normalizeIpInfo(raw) {
125
+ const r = raw && typeof raw === "object" ? raw : {};
126
+ return {
127
+ ip: typeof r.ip === "string" ? r.ip : "",
128
+ area: typeof r.area === "string" ? r.area : "",
129
+ probedAt: typeof r.probedAt === "string" ? r.probedAt : "",
130
+ };
131
+ }
132
+
133
+ // ── chrome backend (chrome.json, key profile_<N>) ────────────────────────────
134
+ function readChromeConfig() {
135
+ if (!fs.existsSync(CHROME_JSON)) return {};
136
+ try {
137
+ return JSON.parse(fs.readFileSync(CHROME_JSON, "utf-8")) || {};
138
+ } catch {
139
+ return {};
140
+ }
141
+ }
142
+
143
+ function writeChromeConfig(next) {
144
+ fs.mkdirSync(path.dirname(CHROME_JSON), { recursive: true });
145
+ fs.writeFileSync(CHROME_JSON, JSON.stringify(next || {}, null, 2), { mode: 0o600 });
146
+ try {
147
+ fs.chmodSync(CHROME_JSON, 0o600);
148
+ } catch {}
149
+ }
150
+
151
+ function chromeView(idx, entry) {
152
+ const e = entry && typeof entry === "object" ? entry : {};
153
+ return {
154
+ id: `chrome-${idx}`,
155
+ backend: "chrome",
156
+ accountIdx: idx,
157
+ name: typeof e.name === "string" && e.name ? e.name : `profile_${idx}`,
158
+ proxy: normalizeProxy(e.proxy),
159
+ logins: (Array.isArray(e.logins) ? e.logins : []).map(normalizeLogin),
160
+ note: typeof e.note === "string" ? e.note : "",
161
+ // chrome-specific extras (read-only passthrough)
162
+ gmail: typeof e.gmail === "string" ? e.gmail : "",
163
+ port: typeof e.port === "number" ? e.port : 11000 + idx,
164
+ rpaDir: typeof e.rpaDir === "string" ? e.rpaDir : `~/chrome/profile_${idx}`,
165
+ platform: e.platform && typeof e.platform === "object" ? e.platform : {},
166
+ ipInfo: normalizeIpInfo(e.ipInfo),
167
+ };
168
+ }
169
+
170
+ function chromeIndices() {
171
+ const data = readChromeConfig();
172
+ return Object.keys(data)
173
+ .map((k) => (/^profile_(\d+)$/.exec(k) ? Number(/^profile_(\d+)$/.exec(k)[1]) : null))
174
+ .filter((n) => typeof n === "number")
175
+ .sort((a, b) => a - b);
176
+ }
177
+
178
+ function mutateChrome(idx, fn) {
179
+ const data = readChromeConfig();
180
+ const key = `profile_${idx}`;
181
+ if (!data[key]) throw new Error(`Missing chrome.json entry: ${key}`);
182
+ data[key] = fn({ ...data[key] }) || data[key];
183
+ data[key].updatedAt = new Date().toISOString();
184
+ writeChromeConfig(data);
185
+ return chromeView(idx, data[key]);
186
+ }
187
+
188
+ // ── electron backend (account-<N>.json) ──────────────────────────────────────
189
+ function electronFile(idx) {
190
+ return path.join(ELECTRON_DIR, `account-${idx}.json`);
191
+ }
192
+
193
+ function readAccount(idx) {
194
+ const f = electronFile(idx);
195
+ if (!fs.existsSync(f)) return null;
196
+ try {
197
+ return JSON.parse(fs.readFileSync(f, "utf-8"));
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ function writeAccount(data) {
204
+ fs.mkdirSync(ELECTRON_DIR, { recursive: true });
205
+ fs.writeFileSync(electronFile(data.accountIdx), JSON.stringify(data, null, 2));
206
+ }
207
+
208
+ function electronView(idx, data) {
209
+ const d = data && typeof data === "object" ? data : { accountIdx: idx };
210
+ const meta = d.metadata && typeof d.metadata === "object" ? d.metadata : {};
211
+ return {
212
+ id: `electron-${idx}`,
213
+ backend: "electron",
214
+ accountIdx: idx,
215
+ name: typeof meta.name === "string" && meta.name ? meta.name : `electron-${idx}`,
216
+ proxy: normalizeProxy(d.proxy),
217
+ logins: (Array.isArray(d.logins) ? d.logins : []).map(normalizeLogin),
218
+ note: typeof d.note === "string" ? d.note : meta.description || "",
219
+ partition: `persist:sandbox-${idx}`,
220
+ ipInfo: normalizeIpInfo(d.ipInfo),
221
+ };
222
+ }
223
+
224
+ function electronIndices() {
225
+ if (!fs.existsSync(ELECTRON_DIR)) return [];
226
+ return fs
227
+ .readdirSync(ELECTRON_DIR)
228
+ .map((f) => (/^account-(\d+)\.json$/.exec(f) ? Number(/^account-(\d+)\.json$/.exec(f)[1]) : null))
229
+ .filter((n) => typeof n === "number")
230
+ .sort((a, b) => a - b);
231
+ }
232
+
233
+ function mutateElectron(idx, fn) {
234
+ let data = readAccount(idx);
235
+ if (!data) {
236
+ data = { accountIdx: idx, createdAt: new Date().toISOString(), windows: [], metadata: {} };
237
+ }
238
+ data = fn(data) || data;
239
+ data.updatedAt = new Date().toISOString();
240
+ writeAccount(data);
241
+ return electronView(idx, data);
242
+ }
243
+
244
+ // ── unified surface (backend = "chrome" | "electron") ────────────────────────
245
+ function listProfiles(backend) {
246
+ if (backend === "chrome") return chromeIndices().map((i) => chromeView(i, readChromeConfig()[`profile_${i}`]));
247
+ if (backend === "electron") return electronIndices().map((i) => electronView(i, readAccount(i)));
248
+ throw new Error(`Unknown backend: ${backend}`);
249
+ }
250
+
251
+ function getProfile(backend, idx) {
252
+ if (backend === "chrome") {
253
+ const e = readChromeConfig()[`profile_${idx}`];
254
+ if (!e) return null;
255
+ return chromeView(idx, e);
256
+ }
257
+ if (backend === "electron") {
258
+ const d = readAccount(idx);
259
+ return d ? electronView(idx, d) : null;
260
+ }
261
+ throw new Error(`Unknown backend: ${backend}`);
262
+ }
263
+
264
+ // setProxy persists the desired proxy (canonical {url, enabled}) to the store.
265
+ // Empty/falsey url clears it. Returns the updated unified view.
266
+ function setProxy(backend, idx, url) {
267
+ const proxy = normalizeProxy(url);
268
+ if (backend === "chrome") return mutateChrome(idx, (e) => ({ ...e, proxy }));
269
+ if (backend === "electron") return mutateElectron(idx, (d) => ({ ...d, proxy }));
270
+ throw new Error(`Unknown backend: ${backend}`);
271
+ }
272
+
273
+ // setLogin — upsert a rich login record (any subset of LOGIN_FIELDS). Keyed by
274
+ // `name` (site name). Works identically for both backends.
275
+ function setLogin(backend, idx, login) {
276
+ if (backend === "chrome") return mutateChrome(idx, (e) => ({ ...e, logins: upsertLoginRich(e.logins, login) }));
277
+ if (backend === "electron") return mutateElectron(idx, (d) => ({ ...d, logins: upsertLoginRich(d.logins, login) }));
278
+ throw new Error(`Unknown backend: ${backend}`);
279
+ }
280
+
281
+ // addLogin — back-compat thin form (platform/account); delegates to setLogin.
282
+ function addLogin(backend, idx, platform, account) {
283
+ return setLogin(backend, idx, { name: platform, username: account });
284
+ }
285
+
286
+ // setIpInfo — persist a freshly probed egress IP + area, stamping probedAt=now.
287
+ function setIpInfo(backend, idx, info) {
288
+ const ipInfo = { ...normalizeIpInfo(info), probedAt: new Date().toISOString() };
289
+ if (backend === "chrome") return mutateChrome(idx, (e) => ({ ...e, ipInfo }));
290
+ if (backend === "electron") return mutateElectron(idx, (d) => ({ ...d, ipInfo }));
291
+ throw new Error(`Unknown backend: ${backend}`);
292
+ }
293
+
294
+ function removeLogin(backend, idx, nameOrUrl) {
295
+ if (backend === "chrome") return mutateChrome(idx, (e) => ({ ...e, logins: removeLoginRich(e.logins, nameOrUrl) }));
296
+ if (backend === "electron") return mutateElectron(idx, (d) => ({ ...d, logins: removeLoginRich(d.logins, nameOrUrl) }));
297
+ throw new Error(`Unknown backend: ${backend}`);
298
+ }
299
+
300
+ function listLogins(backend, idx) {
301
+ const p = getProfile(backend, idx);
302
+ return p ? p.logins : [];
303
+ }
304
+
305
+ module.exports = {
306
+ CHROME_JSON,
307
+ ELECTRON_DIR,
308
+ normalizeProxy,
309
+ proxyRules,
310
+ listProfiles,
311
+ getProfile,
312
+ setProxy,
313
+ setLogin,
314
+ addLogin,
315
+ removeLogin,
316
+ listLogins,
317
+ normalizeLogin,
318
+ LOGIN_FIELDS,
319
+ setIpInfo,
320
+ normalizeIpInfo,
321
+ };
@@ -0,0 +1,95 @@
1
+ // Trusted-origins allowlist — the EXACT-hostname set of sites permitted to
2
+ // receive the electronRPC bridge in profile 0 (i.e. allowed to run exec_shell &
3
+ // friends on THIS machine). Persisted at ~/cicy-ai/db/trusted-origins.json as
4
+ // { "origins": ["app.example.com", …] }.
5
+ //
6
+ // This is the ONLY user-controlled source of trust (see window-utils.isTrustedUrl).
7
+ // • localhost / 127.0.0.1 are always trusted (built-in, non-removable).
8
+ // • Everything else must be added explicitly here (Chrome-style site settings).
9
+ // • Adding a team / backend does NOT grant trust — "add a server" must never
10
+ // implicitly hand a remote origin the ability to run commands locally.
11
+ // • There is deliberately NO domain-suffix wildcard (a public-upload host like
12
+ // r2.deepfetch.de5.net under a trusted suffix would otherwise become a trusted
13
+ // RPC source).
14
+ const fs = require("fs");
15
+ const os = require("os");
16
+ const path = require("path");
17
+ let _audit = () => {};
18
+ try { _audit = require("../utils/rpc-audit").audit; } catch {}
19
+
20
+ const STORE = path.join(os.homedir(), "cicy-ai", "db", "trusted-origins.json");
21
+ const BUILTIN = ["localhost", "127.0.0.1"]; // always trusted, cannot be removed
22
+
23
+ // Normalize arbitrary user input to a bare hostname:
24
+ // "https://X.Com/path?q" → "x.com", "x.com:3000" → "x.com", " x.com " → "x.com".
25
+ // Returns "" when nothing usable / invalid.
26
+ function normalizeHost(input) {
27
+ if (!input || typeof input !== "string") return "";
28
+ let s = input.trim().toLowerCase();
29
+ if (!s) return "";
30
+ if (/^[a-z][a-z0-9+.-]*:\/\//.test(s)) {
31
+ try { return new URL(s).hostname; } catch { return ""; }
32
+ }
33
+ s = s.split("/")[0].split("?")[0].split("#")[0]; // drop path/query/fragment
34
+ s = s.replace(/:\d+$/, ""); // drop :port
35
+ if (!/^[a-z0-9.-]+$/.test(s)) return ""; // basic host charset
36
+ if (s.startsWith(".") || s.endsWith(".") || s.includes("..")) return "";
37
+ return s;
38
+ }
39
+
40
+ function readRaw() {
41
+ try {
42
+ if (!fs.existsSync(STORE)) return [];
43
+ const j = JSON.parse(fs.readFileSync(STORE, "utf-8")) || {};
44
+ return Array.isArray(j.origins) ? j.origins : [];
45
+ } catch { return []; }
46
+ }
47
+
48
+ function writeRaw(origins) {
49
+ fs.mkdirSync(path.dirname(STORE), { recursive: true });
50
+ fs.writeFileSync(STORE, JSON.stringify({ origins }, null, 2), { mode: 0o600 });
51
+ try { fs.chmodSync(STORE, 0o600); } catch {}
52
+ }
53
+
54
+ // User-managed origins only (normalized, de-duped, built-ins excluded).
55
+ function listUser() {
56
+ const seen = new Set();
57
+ const out = [];
58
+ for (const h of readRaw()) {
59
+ const n = normalizeHost(h);
60
+ if (n && !BUILTIN.includes(n) && !seen.has(n)) { seen.add(n); out.push(n); }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ // The full trusted set consumed by isTrustedUrl(): built-ins ∪ user list.
66
+ function listAll() {
67
+ return [...BUILTIN, ...listUser()];
68
+ }
69
+
70
+ // UI shape: each row tagged builtin (greyed / non-removable) or user.
71
+ function listForUi() {
72
+ return [
73
+ ...BUILTIN.map((host) => ({ host, builtin: true })),
74
+ ...listUser().map((host) => ({ host, builtin: false })),
75
+ ];
76
+ }
77
+
78
+ function add(input) {
79
+ const host = normalizeHost(input);
80
+ if (!host) return { ok: false, error: "无效的站点地址" };
81
+ if (BUILTIN.includes(host)) return { ok: true, origins: listForUi() }; // already trusted
82
+ const cur = listUser();
83
+ if (!cur.includes(host)) { writeRaw([...cur, host]); _audit({ kind: "auth", gate: "allowlist", host, decision: "trust-add" }); }
84
+ return { ok: true, origins: listForUi() };
85
+ }
86
+
87
+ function remove(input) {
88
+ const host = normalizeHost(input);
89
+ if (BUILTIN.includes(host)) return { ok: false, error: "内置站点不可删除" };
90
+ const cur = listUser();
91
+ if (cur.includes(host)) { writeRaw(cur.filter((h) => h !== host)); _audit({ kind: "auth", gate: "allowlist", host, decision: "trust-remove" }); }
92
+ return { ok: true, origins: listForUi() };
93
+ }
94
+
95
+ module.exports = { STORE, BUILTIN, normalizeHost, listUser, listAll, listForUi, add, remove };
@@ -16,7 +16,6 @@ function createWorkerObservabilityRoutes({ getWorkerIdentity, getWorkerSnapshot
16
16
  workerId: getWorkerIdentity().workerId,
17
17
  ts: Date.now(),
18
18
  agents: snapshot.agents.length,
19
- artifacts: snapshot.artifacts.length,
20
19
  });
21
20
  });
22
21
 
@@ -30,7 +29,6 @@ function createWorkerObservabilityRoutes({ getWorkerIdentity, getWorkerSnapshot
30
29
  const lines = [
31
30
  `cicy_worker_up 1`,
32
31
  `cicy_worker_agents ${snapshot.agents.length}`,
33
- `cicy_worker_artifacts ${snapshot.artifacts.length}`,
34
32
  `cicy_worker_capabilities ${snapshot.capabilities.length}`,
35
33
  `cicy_worker_memory_rss ${snapshot.resources.memory.rss || 0}`,
36
34
  `cicy_worker_uptime ${snapshot.resources.uptime || 0}`,
@@ -37,6 +37,9 @@ function probeExisting(port = DEFAULT_PORT, timeoutMs = 500) {
37
37
  });
38
38
  }
39
39
 
40
+ // Running-daemon version lives in ONE place now: require("./version").running().
41
+ // update() below uses it to verify what's actually live after a restart.
42
+
40
43
  let child = null;
41
44
 
42
45
  // Runtime Bundle v1 (主人指令): prefer the versioned runtime store on EVERY
@@ -139,12 +142,10 @@ async function start({ logPath, port = DEFAULT_PORT, force = false, version = nu
139
142
  CICY_CODE_PORT: String(port),
140
143
  PORT: String(port),
141
144
  };
145
+ // --helper removed (主人指令): Windows now runs cicy-code in normal mode (full
146
+ // tmux-based multi-agent via the bundled MSYS2 runtime), same as mac/linux —
147
+ // no longer the single headless 团队助手.
142
148
  const args = [];
143
- if (process.platform === "win32") {
144
- // Windows runs the single headless 团队助手 (--helper=1) on w-1001 — no tmux
145
- // panes, so msys2/tmux are NOT bundled or referenced anymore (主人指令 2026-06-08).
146
- args.push("--helper=1");
147
- }
148
149
  child = spawn(exe, args, { stdio, detached: false, windowsHide: true, env });
149
150
  console.log(`[cicy-code-sidecar] spawned ${exe} ${args.join(" ")} pid=${child.pid} port=${port} log=${logPath || "(none)"}`);
150
151
 
@@ -160,6 +161,21 @@ async function start({ logPath, port = DEFAULT_PORT, force = false, version = nu
160
161
  // [] when lsof is missing or nothing is listening.
161
162
  const LSOF_CANDIDATES = ["/usr/sbin/lsof", "/usr/bin/lsof", "lsof"];
162
163
  function listPortPids(port) {
164
+ // Windows has no lsof — find the LISTENING PID on the port via netstat instead.
165
+ // Needed so stop()/update() can actually kill the old cicy-code.exe holding
166
+ // :8008 before launching the new version (else the new one can't bind and the
167
+ // update silently "succeeds" while the OLD version keeps running).
168
+ if (process.platform === "win32") {
169
+ try {
170
+ const out = execFileSync("netstat", ["-ano", "-p", "TCP"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
171
+ const pids = new Set();
172
+ for (const line of out.split(/\r?\n/)) {
173
+ const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
174
+ if (m && Number(m[1]) === port) pids.add(parseInt(m[2], 10));
175
+ }
176
+ return [...pids].filter(n => n > 0);
177
+ } catch { return []; }
178
+ }
163
179
  for (const bin of LSOF_CANDIDATES) {
164
180
  try {
165
181
  const out = execFileSync(bin, ["-nP", `-tiTCP:${port}`, "-sTCP:LISTEN"], {
@@ -213,13 +229,17 @@ async function stop({ timeoutMs = 5000, port = DEFAULT_PORT } = {}) {
213
229
  if (p.exitCode === null) { try { p.kill("SIGKILL"); } catch {} }
214
230
  }
215
231
 
216
- // 2) Anything STILL on :port we didn't spawn — a detached npx from a prior
217
- // launch, a user-run daemon, a PPID=1 orphan. The homepage 停止/重启 must
218
- // act on the REAL listener; otherwise (no tracked child) it would no-op.
219
- // Docker (win32) owns its own lifecycle, so skip the port-kill there.
220
- if (process.platform !== "win32") {
221
- await killPortListeners(port, timeoutMs);
232
+ // 2) Anything STILL on :port we didn't spawn — a detached prior launch, a
233
+ // user-run daemon, an orphan. The homepage 停止/重启 + update() must act on
234
+ // the REAL listener; otherwise (no tracked child) it would no-op.
235
+ // Windows: the old Docker-route skip was WRONG (win is native cicy-code.exe
236
+ // now) it left the old daemon alive so update() couldn't replace the exe
237
+ // (new binary on disk, but the OLD version kept running on :port → "更新完成"
238
+ // yet still old). Hard-kill cicy-code.exe by image name + free the port.
239
+ if (process.platform === "win32") {
240
+ try { execFileSync("taskkill", ["/F", "/IM", "cicy-code.exe"], { stdio: "ignore" }); } catch {}
222
241
  }
242
+ await killPortListeners(port, timeoutMs);
223
243
  }
224
244
 
225
245
  // Remove npx's cached cicy-code installs so the next spawn re-fetches from the
@@ -256,35 +276,76 @@ async function restart({ logPath, port = DEFAULT_PORT } = {}) {
256
276
  // the latest per-platform subpackage into ~/.local/bin as a NEW version-named
257
277
  // binary, re-point cicy-code at it (re-copy on Windows), then stop + start from
258
278
  // that stable path and health-verify.
279
+ let _updating = false;
280
+ function isUpdating() { return _updating; }
281
+
259
282
  async function update({ logPath, port = DEFAULT_PORT, emit } = {}) {
260
283
  const e = emit || (() => {});
261
284
  const localbin = require("./localbin");
285
+ // Suspend the health watchdog for the duration: update() stops cicy-code, then
286
+ // downloads (~30s) before starting the new one — during that gap the watchdog
287
+ // would see the daemon "unreachable" and RESPAWN the OLD binary, racing the
288
+ // swap (holding the port / locking the .exe) so the new version never takes.
289
+ // main.js's watchdog tick checks isUpdating() and skips while this is true.
290
+ _updating = true;
262
291
  try {
292
+ // 主人令:更新 = 杀干净 cicy-code.exe → 起 cicy-code.exe → 探活 → 拿运行中真实
293
+ // version → 再判定"已是最新"。绝不凭磁盘 manifest 直接喊"已是最新"——manifest
294
+ // 可能比运行中的进程超前,甚至 daemon 根本没起。唯一可信的是运行中 /api/health
295
+ // 报的版本。所以这个流程对"已是最新"和"要升级"两种情况一视同仁:总是重启 + 验证。
263
296
  e({ phase: "download", status: "running", message: "检查最新版本…" });
264
- const cur = localbin.currentVersion();
265
297
  const latest = await localbin.latestVersion();
266
298
  if (!latest) throw new Error("无法获取最新版本号");
267
- if (cur && localbin.cmpVer(latest, cur) <= 0) {
268
- // Already current no download, no restart (不重复下载/更新).
269
- e({ phase: "done", status: "done", message: `已是最新 ${cur}` });
270
- return null;
271
- }
272
- await localbin.fetchToLocalBin(latest, { emit }); // download → ~/.local/bin → re-link
273
- e({ phase: "swap", status: "running", message: `切换到 ${latest},启动…` });
299
+ const cur = localbin.currentVersion(); // 磁盘 manifest:只用来决定要不要下载
300
+ const needDownload = !cur || localbin.cmpVer(latest, cur) > 0;
301
+
302
+ // 1) 杀干净
303
+ e({ phase: "swap", status: "running", message: "停止 cicy-code…" });
274
304
  await stop({ port });
275
- await new Promise(r => setTimeout(r, 300));
305
+ await new Promise(r => setTimeout(r, 400));
306
+
307
+ // 2) 落后才下载(此时 cicy-code.exe 已死,Windows 也能覆盖)
308
+ if (needDownload) {
309
+ e({ phase: "download", status: "running", message: `下载 ${latest}…` });
310
+ await localbin.fetchToLocalBin(latest, { emit });
311
+ }
312
+
313
+ // 3) 起
314
+ e({ phase: "swap", status: "running", message: "启动 cicy-code…" });
276
315
  const c = await start({ logPath, port, force: true });
316
+
317
+ // 4) 探活:等 TCP 监听起来
318
+ let up = false;
277
319
  for (let i = 0; i < 120; i++) {
278
- if (await probeExisting(port)) { e({ phase: "done", status: "done", message: `已更新到 ${latest}` }); return c; }
320
+ if (await probeExisting(port)) { up = true; break; }
279
321
  await new Promise(r => setTimeout(r, 500));
280
322
  }
281
- e({ phase: "done", status: "error", message: "新版本未在 60s 内就绪" });
323
+ if (!up) { e({ phase: "done", status: "error", message: "cicy-code 未在 60s 内启动" }); return c; }
324
+
325
+ // 5) 拿运行中真实 version(唯一来源 version.running();可能略慢于 TCP,重试几次)
326
+ const version = require("./version");
327
+ let running = "";
328
+ for (let i = 0; i < 20 && !running; i++) {
329
+ running = await version.running(port);
330
+ if (!running) await new Promise(r => setTimeout(r, 500));
331
+ }
332
+
333
+ // 6) 以运行中真实版本判定——不撒谎
334
+ if (running && localbin.cmpVer(running, latest) >= 0) {
335
+ e({ phase: "done", status: "done", message: `已是最新 ${running}` });
336
+ } else if (running) {
337
+ e({ phase: "done", status: "done", message: `已更新到 ${running}` });
338
+ } else {
339
+ e({ phase: "done", status: "done", message: `已启动(版本未知,期望 ${latest})` });
340
+ }
282
341
  return c;
283
342
  } catch (err) {
284
343
  console.warn(`[cicy-code-sidecar] update failed: ${err.message}`);
285
344
  e({ phase: "done", status: "error", message: `更新失败:${err.message}` });
286
345
  return null;
346
+ } finally {
347
+ _updating = false;
287
348
  }
288
349
  }
289
350
 
290
- module.exports = { start, stop, restart, update, probeExisting, clearNpxCache };
351
+ module.exports = { start, stop, restart, update, probeExisting, clearNpxCache, isUpdating };
@@ -133,9 +133,26 @@ async function latestVersion(name = DEFAULT) {
133
133
  function linkTo(name, verBinPath, ver) {
134
134
  const link = linkFor(name);
135
135
  fs.mkdirSync(LOCAL_BIN, { recursive: true });
136
- try { fs.rmSync(link, { force: true }); } catch {}
137
- if (IS_WIN) fs.copyFileSync(verBinPath, link);
138
- else fs.symlinkSync(verBinPath, link);
136
+ if (IS_WIN) {
137
+ // Windows can't DELETE or OVERWRITE a RUNNING .exe (EPERM/EBUSY) — which is
138
+ // exactly the update case, since cicy-code.exe is live when we re-link. But
139
+ // Windows CAN *rename* a running exe: move the in-use link aside (unique name
140
+ // so it never collides with an older still-running one), then copy the new
141
+ // binary into place. The old process keeps running from the renamed file; the
142
+ // next start picks up the new one. (Stale .old-* are harmless; best-effort swept.)
143
+ try {
144
+ if (fs.existsSync(link)) fs.renameSync(link, `${link}.old-${Date.now()}`);
145
+ } catch {}
146
+ fs.copyFileSync(verBinPath, link);
147
+ try {
148
+ for (const f of fs.readdirSync(LOCAL_BIN)) {
149
+ if (f.startsWith(`${BIN}.old-`)) { try { fs.rmSync(path.join(LOCAL_BIN, f), { force: true }); } catch {} }
150
+ }
151
+ } catch {}
152
+ } else {
153
+ try { fs.rmSync(link, { force: true }); } catch {}
154
+ fs.symlinkSync(verBinPath, link);
155
+ }
139
156
  if (ver) writeManifest(name, ver);
140
157
  return link;
141
158
  }
@@ -140,9 +140,9 @@ async function start({ port = 8008, logPath = null, emit, version = null } = {})
140
140
  PORT: String(port),
141
141
  CICY_CODE_PORT: String(port),
142
142
  };
143
- // --helper=1: on Windows, boot as the single headless cicy 团队助手 on w-1001
144
- // (开机即团队助手). The flag is a no-op on cicy-code builds that don't support it.
145
- const child = spawn(exe, ["--helper=1"], { stdio, detached: true, windowsHide: true, env });
143
+ // --helper removed (主人指令): boot cicy-code in normal mode (full tmux-based
144
+ // multi-agent), not the single headless 团队助手.
145
+ const child = spawn(exe, [], { stdio, detached: true, windowsHide: true, env });
146
146
  child.unref();
147
147
  try { fs.writeFileSync(PID_FILE, String(child.pid)); } catch {}
148
148
  console.log(`[native-sidecar] spawned ${exe} pid=${child.pid} port=${port} log=${logPath || "(none)"}`);