@web-auto/webauto 0.1.17 → 0.1.19

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 (65) hide show
  1. package/README.md +122 -53
  2. package/apps/desktop-console/dist/main/index.mjs +229 -14
  3. package/apps/desktop-console/dist/renderer/index.js +237 -8
  4. package/apps/desktop-console/entry/ui-cli.mjs +290 -21
  5. package/apps/desktop-console/entry/ui-console.mjs +46 -15
  6. package/apps/webauto/entry/account.mjs +126 -27
  7. package/apps/webauto/entry/lib/account-detect.mjs +399 -9
  8. package/apps/webauto/entry/lib/account-store.mjs +201 -109
  9. package/apps/webauto/entry/lib/iflow-reply.mjs +194 -0
  10. package/apps/webauto/entry/lib/profile-policy.mjs +48 -0
  11. package/apps/webauto/entry/lib/profilepool.mjs +12 -0
  12. package/apps/webauto/entry/lib/schedule-store.mjs +29 -2
  13. package/apps/webauto/entry/lib/session-init.mjs +227 -0
  14. package/apps/webauto/entry/lib/upgrade-check.mjs +269 -0
  15. package/apps/webauto/entry/lib/xhs-unified-blocks.mjs +160 -0
  16. package/apps/webauto/entry/lib/xhs-unified-output-blocks.mjs +83 -0
  17. package/apps/webauto/entry/lib/xhs-unified-plan-blocks.mjs +55 -0
  18. package/apps/webauto/entry/lib/xhs-unified-profile-blocks.mjs +542 -0
  19. package/apps/webauto/entry/lib/xhs-unified-runtime-blocks.mjs +436 -0
  20. package/apps/webauto/entry/profilepool.mjs +56 -9
  21. package/apps/webauto/entry/smart-reply-cli.mjs +267 -0
  22. package/apps/webauto/entry/weibo-unified.mjs +84 -11
  23. package/apps/webauto/entry/xhs-orchestrate.mjs +43 -1
  24. package/apps/webauto/entry/xhs-unified.mjs +92 -997
  25. package/bin/webauto.mjs +22 -4
  26. package/dist/modules/camo-backend/src/index.js +33 -0
  27. package/dist/modules/camo-backend/src/internal/BrowserSession.js +232 -49
  28. package/dist/modules/camo-backend/src/internal/engine-manager.js +14 -13
  29. package/dist/modules/camo-backend/src/internal/ws-server.js +16 -19
  30. package/dist/modules/camo-runtime/src/utils/browser-service.mjs +38 -6
  31. package/dist/modules/workflow/blocks/EnsureSession.js +0 -8
  32. package/dist/modules/workflow/blocks/WeiboCollectFromLinksBlock.js +78 -6
  33. package/dist/modules/workflow/blocks/WeiboCollectSearchLinksBlock.js +266 -192
  34. package/dist/modules/workflow/definitions/weibo-search-workflow-v1.js +2 -0
  35. package/dist/modules/workflow/src/runner.js +2 -0
  36. package/dist/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.js +150 -37
  37. package/dist/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.js +491 -0
  38. package/modules/camo-backend/src/index.ts +31 -0
  39. package/modules/camo-backend/src/internal/BrowserSession.ts +224 -53
  40. package/modules/camo-backend/src/internal/engine-manager.ts +14 -15
  41. package/modules/camo-backend/src/internal/ws-server.ts +17 -17
  42. package/modules/camo-runtime/src/autoscript/action-providers/xhs/common.mjs +12 -2
  43. package/modules/camo-runtime/src/autoscript/action-providers/xhs/persistence.mjs +57 -0
  44. package/modules/camo-runtime/src/autoscript/action-providers/xhs.mjs +2475 -243
  45. package/modules/camo-runtime/src/autoscript/runtime.mjs +35 -30
  46. package/modules/camo-runtime/src/autoscript/xhs-unified-template.mjs +80 -443
  47. package/modules/camo-runtime/src/container/runtime-core/checkpoint.mjs +39 -6
  48. package/modules/camo-runtime/src/container/runtime-core/operations/index.mjs +206 -39
  49. package/modules/camo-runtime/src/container/runtime-core/operations/tab-pool.mjs +0 -79
  50. package/modules/camo-runtime/src/container/runtime-core/operations/viewport.mjs +46 -0
  51. package/modules/camo-runtime/src/utils/browser-service.mjs +41 -6
  52. package/modules/camo-runtime/src/utils/js-policy.mjs +28 -0
  53. package/modules/workflow/blocks/EnsureSession.ts +0 -4
  54. package/modules/workflow/blocks/WeiboCollectFromLinksBlock.ts +81 -6
  55. package/modules/workflow/blocks/WeiboCollectSearchLinksBlock.ts +316 -0
  56. package/modules/workflow/definitions/weibo-search-workflow-v1.ts +2 -0
  57. package/modules/workflow/src/runner.ts +2 -0
  58. package/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.ts +198 -53
  59. package/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.ts +706 -0
  60. package/package.json +2 -2
  61. package/modules/camo-runtime/src/autoscript/action-providers/xhs/comments.mjs +0 -498
  62. package/modules/camo-runtime/src/autoscript/action-providers/xhs/detail.mjs +0 -181
  63. package/modules/camo-runtime/src/autoscript/action-providers/xhs/interaction.mjs +0 -691
  64. package/modules/camo-runtime/src/autoscript/action-providers/xhs/search.mjs +0 -388
  65. package/modules/camo-runtime/src/container/runtime-core/operations/selector-scripts.mjs +0 -135
@@ -3,7 +3,7 @@ import minimist from 'minimist';
3
3
  import { spawn } from 'node:child_process';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { fileURLToPath } from 'node:url';
8
8
 
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -18,6 +18,8 @@ const readEnvPositiveInt = (name, fallback) => {
18
18
  const DEFAULT_HTTP_TIMEOUT_MS = readEnvPositiveInt('WEBAUTO_UI_CLI_HTTP_TIMEOUT_MS', 25_000);
19
19
  const DEFAULT_HTTP_RETRIES = readEnvPositiveInt('WEBAUTO_UI_CLI_HTTP_RETRIES', 1);
20
20
  const DEFAULT_START_HEALTH_TIMEOUT_MS = readEnvPositiveInt('WEBAUTO_UI_CLI_START_HEALTH_TIMEOUT_MS', 8_000);
21
+ const DEFAULT_STATUS_TIMEOUT_MS = readEnvPositiveInt('WEBAUTO_UI_CLI_STATUS_TIMEOUT_MS', 45_000);
22
+ const DEFAULT_ACTION_HTTP_TIMEOUT_MS = readEnvPositiveInt('WEBAUTO_UI_CLI_ACTION_HTTP_TIMEOUT_MS', 40_000);
21
23
 
22
24
  function normalizePathForPlatform(raw, platform = process.platform) {
23
25
  const input = String(raw || '').trim();
@@ -57,7 +59,7 @@ const CONTROL_FILE = path.join(resolveWebautoRoot(), 'run', 'ui-cli.json');
57
59
 
58
60
  const args = minimist(process.argv.slice(2), {
59
61
  boolean: ['help', 'json', 'auto-start', 'build', 'install', 'continue-on-error', 'exact', 'keep-open', 'detailed'],
60
- string: ['host', 'port', 'selector', 'value', 'text', 'key', 'tab', 'label', 'state', 'file', 'output', 'timeout', 'interval', 'nth'],
62
+ string: ['host', 'port', 'selector', 'value', 'text', 'key', 'tab', 'label', 'state', 'file', 'output', 'timeout', 'interval', 'nth', 'reason'],
61
63
  alias: { h: 'help' },
62
64
  default: { 'auto-start': false, json: false, 'keep-open': false },
63
65
  });
@@ -82,6 +84,7 @@ Usage:
82
84
  webauto ui cli full-cover [--build] [--install] [--output <report.json>] [--keep-open]
83
85
  webauto ui cli run --file <steps.json> [--continue-on-error]
84
86
  webauto ui cli stop
87
+ webauto ui cli restart [--reason <text>] [--timeout <ms>]
85
88
 
86
89
  Options:
87
90
  --host <host> UI CLI bridge host (default 127.0.0.1)
@@ -107,18 +110,41 @@ function parseIntSafe(input, fallback) {
107
110
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
108
111
  }
109
112
 
113
+ function buildUiCliClientMeta(cmd = '') {
114
+ return {
115
+ client: 'webauto-ui-cli',
116
+ cmd: String(cmd || '').trim() || null,
117
+ pid: process.pid,
118
+ ppid: process.ppid,
119
+ };
120
+ }
121
+
110
122
  function readControlFile() {
111
123
  try {
112
124
  if (!existsSync(CONTROL_FILE)) return null;
113
125
  const raw = JSON.parse(readFileSync(CONTROL_FILE, 'utf8'));
114
126
  const host = String(raw?.host || '').trim() || DEFAULT_HOST;
115
127
  const port = parseIntSafe(raw?.port, DEFAULT_PORT);
116
- return { host, port };
128
+ const pid = parseIntSafe(raw?.pid, 0);
129
+ return {
130
+ host,
131
+ port,
132
+ pid: pid > 0 ? pid : null,
133
+ startedAt: String(raw?.startedAt || '').trim() || null,
134
+ };
117
135
  } catch {
118
136
  return null;
119
137
  }
120
138
  }
121
139
 
140
+ function removeControlFileIfPresent() {
141
+ try {
142
+ rmSync(CONTROL_FILE, { force: true });
143
+ } catch {
144
+ // ignore
145
+ }
146
+ }
147
+
122
148
  function resolveEndpoint() {
123
149
  const fromFile = readControlFile();
124
150
  const host = String(args.host || fromFile?.host || DEFAULT_HOST).trim();
@@ -165,6 +191,52 @@ function sleep(ms) {
165
191
  return new Promise((resolve) => setTimeout(resolve, ms));
166
192
  }
167
193
 
194
+ function isProcessAlive(pid) {
195
+ const targetPid = parseIntSafe(pid, 0);
196
+ if (targetPid <= 0) return false;
197
+ try {
198
+ process.kill(targetPid, 0);
199
+ return true;
200
+ } catch (err) {
201
+ if (err?.code === 'ESRCH') return false;
202
+ return true;
203
+ }
204
+ }
205
+
206
+ async function terminatePid(pid) {
207
+ const targetPid = parseIntSafe(pid, 0);
208
+ if (targetPid <= 0) return { ok: false, error: 'invalid_pid' };
209
+ if (process.platform === 'win32') {
210
+ return new Promise((resolve) => {
211
+ const child = spawn('taskkill', ['/PID', String(targetPid), '/T', '/F'], {
212
+ windowsHide: true,
213
+ stdio: ['ignore', 'pipe', 'pipe'],
214
+ });
215
+ let stderr = '';
216
+ child.stderr.on('data', (chunk) => { stderr += String(chunk || ''); });
217
+ child.on('error', (err) => resolve({ ok: false, error: err?.message || String(err) }));
218
+ child.on('close', (code) => {
219
+ if (code === 0) return resolve({ ok: true, pid: targetPid });
220
+ if (!isProcessAlive(targetPid)) {
221
+ return resolve({ ok: true, pid: targetPid, alreadyStopped: true });
222
+ }
223
+ return resolve({
224
+ ok: false,
225
+ pid: targetPid,
226
+ error: stderr.trim() || `taskkill_exit_${code}`,
227
+ });
228
+ });
229
+ });
230
+ }
231
+ try {
232
+ process.kill(targetPid, 'SIGTERM');
233
+ return { ok: true, pid: targetPid };
234
+ } catch (err) {
235
+ if (err?.code === 'ESRCH') return { ok: true, pid: targetPid, alreadyStopped: true };
236
+ return { ok: false, pid: targetPid, error: err?.message || String(err) };
237
+ }
238
+ }
239
+
168
240
  async function waitForHealth(endpoint, timeoutMs = 30_000) {
169
241
  const started = Date.now();
170
242
  while (Date.now() - started <= timeoutMs) {
@@ -179,9 +251,60 @@ async function waitForHealth(endpoint, timeoutMs = 30_000) {
179
251
  return null;
180
252
  }
181
253
 
254
+ async function waitForHealthDown(endpoint, timeoutMs = 15_000) {
255
+ const started = Date.now();
256
+ while (Date.now() - started <= timeoutMs) {
257
+ try {
258
+ const ret = await requestJson(endpoint, '/health', { timeoutMs: 1500, retries: 0 });
259
+ if (!ret.ok || !ret.json?.ok) return true;
260
+ } catch {
261
+ return true;
262
+ }
263
+ await sleep(300);
264
+ }
265
+ return false;
266
+ }
267
+
268
+ async function probeActionChannel(endpoint, timeoutMs = 6000) {
269
+ try {
270
+ const ret = await requestJson(endpoint, '/action', {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/json' },
273
+ body: JSON.stringify({ action: 'probe', selector: 'body', _client: buildUiCliClientMeta('probe') }),
274
+ timeoutMs,
275
+ retries: 0,
276
+ });
277
+ return ret.ok && Boolean(ret.json?.ok);
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ function resolveKnownPid(statusRet = null) {
284
+ const fromHealth = parseIntSafe(statusRet?.json?.pid, 0);
285
+ if (fromHealth > 0) return fromHealth;
286
+ const fromFile = parseIntSafe(readControlFile()?.pid, 0);
287
+ if (fromFile > 0) return fromFile;
288
+ return 0;
289
+ }
290
+
182
291
  async function startConsoleIfNeeded(endpoint) {
183
292
  const health = await waitForHealth(endpoint, 3000);
184
- if (health) return health;
293
+ if (health) {
294
+ const channelReady = await probeActionChannel(endpoint);
295
+ if (channelReady) return health;
296
+ const pid = parseIntSafe(health?.pid, 0) || parseIntSafe(readControlFile()?.pid, 0);
297
+ if (pid > 0) await terminatePid(pid);
298
+ removeControlFileIfPresent();
299
+ await sleep(500);
300
+ } else {
301
+ const stalePid = parseIntSafe(readControlFile()?.pid, 0);
302
+ if (stalePid > 0) {
303
+ await terminatePid(stalePid);
304
+ removeControlFileIfPresent();
305
+ await sleep(500);
306
+ }
307
+ }
185
308
 
186
309
  const uiConsoleScript = path.join(APP_ROOT, 'entry', 'ui-console.mjs');
187
310
  const runUiConsole = async (extraArgs = []) => {
@@ -205,6 +328,13 @@ async function startConsoleIfNeeded(endpoint) {
205
328
 
206
329
  const ready = await waitForHealth(endpoint, 45_000);
207
330
  if (!ready) throw new Error('ui cli bridge is not ready after start');
331
+ const readyChannel = await probeActionChannel(endpoint);
332
+ if (!readyChannel) {
333
+ const pid = parseIntSafe(ready?.pid, 0);
334
+ if (pid > 0) await terminatePid(pid);
335
+ removeControlFileIfPresent();
336
+ throw new Error('ui cli bridge action channel is not ready after start');
337
+ }
208
338
  return ready;
209
339
  }
210
340
 
@@ -221,15 +351,20 @@ function printOutput(payload) {
221
351
  }
222
352
 
223
353
  async function sendAction(endpoint, payload) {
224
- const waitBudgetMs = payload?.action === 'wait'
354
+ const actionBudgetMs = payload?.action === 'wait'
225
355
  ? parseIntSafe(payload?.timeoutMs, 15_000) + 5_000
226
- : 0;
227
- const timeoutMs = Math.max(DEFAULT_HTTP_TIMEOUT_MS, waitBudgetMs);
356
+ : DEFAULT_ACTION_HTTP_TIMEOUT_MS;
357
+ const timeoutMs = Math.max(DEFAULT_HTTP_TIMEOUT_MS, actionBudgetMs);
228
358
  const retries = payload?.action === 'wait' ? 0 : DEFAULT_HTTP_RETRIES;
359
+ const baseCmd = String(args._[0] || '').trim();
360
+ const bodyPayload = {
361
+ ...(payload || {}),
362
+ _client: buildUiCliClientMeta(baseCmd || payload?.action || ''),
363
+ };
229
364
  return requestJson(endpoint, '/action', {
230
365
  method: 'POST',
231
366
  headers: { 'Content-Type': 'application/json' },
232
- body: JSON.stringify(payload),
367
+ body: JSON.stringify(bodyPayload),
233
368
  timeoutMs,
234
369
  retries,
235
370
  });
@@ -541,14 +676,15 @@ async function runFullCover(endpoint) {
541
676
  await click('#scheduler-dryrun');
542
677
  await input('#scheduler-like-keywords', '真牛逼,购买链接');
543
678
  await click('#scheduler-save-btn');
544
- await wait('#scheduler-list');
545
- // Wait for async schedule refresh to render the new task.
546
- for (let i = 0; i < 6; i += 1) {
547
- const raw = await probeRaw('#scheduler-list', { text: taskName });
548
- if (Number(raw?.textMatchedCount || 0) > 0) break;
549
- await sleep(500);
550
- }
551
- await runProbe('scheduler', '#scheduler-list', { text: taskName });
679
+ await waitForElement('#scheduler-list', 40, 250);
680
+ await runProbe('scheduler', '#scheduler-list');
681
+ // Record whether the newly saved task name is visible, but don't fail hard here.
682
+ // The scheduler list can transiently refresh and reorder under active datasets.
683
+ const taskNameProbe = await probeRaw('#scheduler-list', { text: taskName });
684
+ pushStep('scheduler:task_name_visible', Number(taskNameProbe?.textMatchedCount || 0) > 0, {
685
+ payload: { selector: '#scheduler-list', text: taskName },
686
+ result: taskNameProbe,
687
+ });
552
688
  await runProbe('scheduler', '#scheduler-list button', { text: '编辑' });
553
689
  await runProbe('scheduler', '#scheduler-list button', { text: '执行' });
554
690
  await runProbe('scheduler', '#scheduler-list button', { text: '导出' });
@@ -679,9 +815,10 @@ async function main() {
679
815
  }
680
816
 
681
817
  if (cmd === 'status' || cmd === 'snapshot') {
682
- const pathName = cmd === 'snapshot' ? '/snapshot' : '/status';
818
+ const pathName = cmd === 'snapshot' ? '/snapshot' : '/health';
819
+ const statusTimeoutMs = parseIntSafe(args.timeout, DEFAULT_STATUS_TIMEOUT_MS);
683
820
  const ret = await requestJson(endpoint, pathName, {
684
- timeoutMs: parseIntSafe(args.timeout, DEFAULT_HTTP_TIMEOUT_MS),
821
+ timeoutMs: statusTimeoutMs,
685
822
  retries: DEFAULT_HTTP_RETRIES,
686
823
  });
687
824
  if (!ret.ok) throw new Error(ret.json?.error || `http_${ret.status}`);
@@ -690,9 +827,141 @@ async function main() {
690
827
  }
691
828
 
692
829
  if (cmd === 'stop') {
693
- const ret = await sendAction(endpoint, { action: 'close_window' });
694
- if (!ret.ok) throw new Error(ret.json?.error || `http_${ret.status}`);
695
- printOutput(ret.json);
830
+ let statusRet = null;
831
+ try {
832
+ statusRet = await requestJson(endpoint, '/health', {
833
+ timeoutMs: Math.min(8000, parseIntSafe(args.timeout, DEFAULT_HTTP_TIMEOUT_MS)),
834
+ retries: 0,
835
+ });
836
+ } catch {
837
+ statusRet = null;
838
+ }
839
+ const knownPid = resolveKnownPid(statusRet);
840
+ const ret = await requestJson(endpoint, '/action', {
841
+ method: 'POST',
842
+ headers: { 'Content-Type': 'application/json' },
843
+ body: JSON.stringify({ action: 'close_window', _client: buildUiCliClientMeta('stop') }),
844
+ timeoutMs: Math.min(8000, parseIntSafe(args.timeout, DEFAULT_ACTION_HTTP_TIMEOUT_MS)),
845
+ retries: 0,
846
+ }).catch((error) => ({
847
+ ok: false,
848
+ status: 0,
849
+ json: { ok: false, error: error?.message || String(error) },
850
+ }));
851
+ if (ret.ok && ret.json?.ok) {
852
+ const down = await waitForHealthDown(endpoint, 12_000);
853
+ if (down) {
854
+ removeControlFileIfPresent();
855
+ printOutput(ret.json);
856
+ return;
857
+ }
858
+ if (knownPid > 0) {
859
+ const killed = await terminatePid(knownPid);
860
+ if (!killed.ok) {
861
+ throw new Error(`close_window pending, force-stop failed: ${killed.error || 'unknown_error'}`);
862
+ }
863
+ removeControlFileIfPresent();
864
+ printOutput({
865
+ ok: true,
866
+ forced: true,
867
+ pid: knownPid,
868
+ reason: 'close_window_timeout',
869
+ });
870
+ return;
871
+ }
872
+ printOutput(ret.json);
873
+ return;
874
+ }
875
+
876
+ const actionError = String(ret?.json?.error || `http_${ret?.status || 'request'}`).trim() || 'unknown_error';
877
+ if (knownPid > 0) {
878
+ const killed = await terminatePid(knownPid);
879
+ if (!killed.ok) {
880
+ throw new Error(`force-stop failed: ${killed.error || 'unknown_error'}`);
881
+ }
882
+ removeControlFileIfPresent();
883
+ printOutput({
884
+ ok: true,
885
+ forced: true,
886
+ pid: knownPid,
887
+ reason: actionError || 'request_failed',
888
+ });
889
+ return;
890
+ }
891
+ throw new Error(actionError);
892
+ }
893
+
894
+ if (cmd === 'restart') {
895
+ let statusRet = null;
896
+ try {
897
+ statusRet = await requestJson(endpoint, '/health', {
898
+ timeoutMs: Math.min(8000, parseIntSafe(args.timeout, DEFAULT_HTTP_TIMEOUT_MS)),
899
+ retries: 0,
900
+ });
901
+ } catch {
902
+ statusRet = null;
903
+ }
904
+ const knownPid = resolveKnownPid(statusRet);
905
+ const restartReason = String(args.reason || '').trim() || 'ui_cli';
906
+ const ret = await requestJson(endpoint, '/action', {
907
+ method: 'POST',
908
+ headers: { 'Content-Type': 'application/json' },
909
+ body: JSON.stringify({ action: 'restart', reason: restartReason, _client: buildUiCliClientMeta('restart') }),
910
+ timeoutMs: Math.min(8000, parseIntSafe(args.timeout, DEFAULT_ACTION_HTTP_TIMEOUT_MS)),
911
+ retries: 0,
912
+ }).catch((error) => ({
913
+ ok: false,
914
+ status: 0,
915
+ json: { ok: false, error: error?.message || String(error) },
916
+ }));
917
+ if (!ret.ok || !ret.json?.ok) {
918
+ const actionError = String(ret?.json?.error || `http_${ret?.status || 'request'}`).trim() || 'unknown_error';
919
+ if (knownPid > 0) {
920
+ await terminatePid(knownPid);
921
+ removeControlFileIfPresent();
922
+ }
923
+ const recovered = await startConsoleIfNeeded(endpoint);
924
+ printOutput({
925
+ ok: true,
926
+ restarting: true,
927
+ reason: restartReason,
928
+ recoveredByForceRestart: true,
929
+ previousPid: knownPid > 0 ? knownPid : null,
930
+ status: recovered,
931
+ });
932
+ return;
933
+ }
934
+
935
+ const transitionBudgetMs = Math.min(15_000, Math.max(2_000, parseIntSafe(args.timeout, 60_000)));
936
+ const transitionStart = Date.now();
937
+ while (Date.now() - transitionStart <= transitionBudgetMs) {
938
+ try {
939
+ const probe = await requestJson(endpoint, '/health', { timeoutMs: 1500, retries: 0 });
940
+ const probePid = Number(probe?.json?.pid || 0);
941
+ if (!probe.ok || !probe.json?.ok) break;
942
+ if (knownPid > 0 && probePid > 0 && probePid !== knownPid) break;
943
+ } catch {
944
+ break;
945
+ }
946
+ await sleep(250);
947
+ }
948
+
949
+ const restartWaitMs = parseIntSafe(args.timeout, 90_000);
950
+ let status = await waitForHealth(endpoint, restartWaitMs);
951
+ if (!status || (knownPid > 0 && Number(status?.pid || 0) === knownPid)) {
952
+ if (knownPid > 0) {
953
+ await terminatePid(knownPid);
954
+ removeControlFileIfPresent();
955
+ }
956
+ status = await startConsoleIfNeeded(endpoint);
957
+ }
958
+ printOutput({
959
+ ok: true,
960
+ restarting: true,
961
+ reason: restartReason,
962
+ previousPid: knownPid > 0 ? knownPid : null,
963
+ status,
964
+ });
696
965
  return;
697
966
  }
698
967
 
@@ -94,8 +94,17 @@ function checkBuildStatus() {
94
94
  return existsSync(DIST_MAIN);
95
95
  }
96
96
 
97
- function quotePsSingle(value) {
98
- return String(value || '').replace(/'/g, "''");
97
+ function resolveElectronBin() {
98
+ const distDir = path.join(APP_ROOT, 'node_modules', 'electron', 'dist');
99
+ const candidates = process.platform === 'win32'
100
+ ? [path.join(distDir, 'electron.exe')]
101
+ : (process.platform === 'darwin'
102
+ ? [
103
+ path.join(distDir, 'electron'),
104
+ path.join(distDir, 'Electron.app', 'Contents', 'MacOS', 'Electron'),
105
+ ]
106
+ : [path.join(distDir, 'electron')]);
107
+ return candidates.find((item) => existsSync(item)) || candidates[0];
99
108
  }
100
109
 
101
110
  function sleep(ms) {
@@ -119,6 +128,26 @@ async function waitForCoreServicesHealthy(timeoutMs = 90000) {
119
128
  return false;
120
129
  }
121
130
 
131
+ async function readWindowsSessionId(pid) {
132
+ const targetPid = Number(pid || 0);
133
+ if (!Number.isFinite(targetPid) || targetPid <= 0 || process.platform !== 'win32') return null;
134
+ return new Promise((resolve) => {
135
+ const child = spawn('wmic', ['process', 'where', `processid=${targetPid}`, 'get', 'SessionId', '/value'], {
136
+ windowsHide: true,
137
+ stdio: ['ignore', 'pipe', 'ignore'],
138
+ });
139
+ let stdout = '';
140
+ child.stdout.on('data', (chunk) => { stdout += String(chunk || ''); });
141
+ child.on('error', () => resolve(null));
142
+ child.on('close', () => {
143
+ const match = stdout.match(/SessionId\s*=\s*(\d+)/i);
144
+ if (!match) return resolve(null);
145
+ const sessionId = Number(match[1]);
146
+ resolve(Number.isFinite(sessionId) ? sessionId : null);
147
+ });
148
+ });
149
+ }
150
+
122
151
  function terminateProcessTree(pid) {
123
152
  const target = Number(pid || 0);
124
153
  if (!Number.isFinite(target) || target <= 0) return;
@@ -174,9 +203,7 @@ async function startConsole(noDaemon = false) {
174
203
  if (noDaemon) env.WEBAUTO_NO_DAEMON = '1';
175
204
  const detached = !noDaemon;
176
205
  const stdio = detached ? 'ignore' : 'inherit';
177
- const electronBin = process.platform === 'win32'
178
- ? path.join(APP_ROOT, 'node_modules', 'electron', 'dist', 'electron.exe')
179
- : path.join(APP_ROOT, 'node_modules', 'electron', 'dist', 'electron');
206
+ const electronBin = resolveElectronBin();
180
207
  if (!existsSync(electronBin)) {
181
208
  throw new Error(`electron binary not found: ${electronBin}`);
182
209
  }
@@ -184,12 +211,12 @@ async function startConsole(noDaemon = false) {
184
211
  const spawnArgs = [DIST_MAIN];
185
212
 
186
213
  if (process.platform === 'win32' && detached) {
187
- const filePath = electronBin;
188
- const argList = [DIST_MAIN];
189
- const psArgList = argList.map((item) => `'${quotePsSingle(item)}'`).join(',');
190
- const psScript = `$p = Start-Process -FilePath '${quotePsSingle(filePath)}' -ArgumentList @(${psArgList}) -WorkingDirectory '${quotePsSingle(APP_ROOT)}' -PassThru; Write-Output $p.Id`;
214
+ const escaped = (input) => String(input || '').replace(/"/g, '""');
215
+ const commandLine = [`"${escaped(electronBin)}"`]
216
+ .concat(spawnArgs.map((arg) => `"${escaped(arg)}"`))
217
+ .join(' ');
191
218
  const pid = await new Promise((resolve, reject) => {
192
- const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psScript], {
219
+ const child = spawn('wmic', ['process', 'call', 'create', commandLine], {
193
220
  cwd: APP_ROOT,
194
221
  env,
195
222
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -201,12 +228,12 @@ async function startConsole(noDaemon = false) {
201
228
  child.stderr.on('data', (chunk) => { stderr += String(chunk || ''); });
202
229
  child.on('error', reject);
203
230
  child.on('close', (code) => {
204
- if (code === 0) {
205
- const pid = String(stdout || '').trim().split(/\s+/).pop();
206
- resolve(pid || 'unknown');
207
- } else {
208
- reject(new Error(`Start-Process failed (${code}): ${stderr.trim() || stdout.trim() || 'unknown error'}`));
231
+ if (code !== 0) {
232
+ reject(new Error(`wmic create failed (${code}): ${stderr.trim() || stdout.trim() || 'unknown error'}`));
233
+ return;
209
234
  }
235
+ const match = stdout.match(/ProcessId\s*=\s*(\d+)/i);
236
+ resolve(match?.[1] || 'unknown');
210
237
  });
211
238
  });
212
239
  const healthy = await waitForCoreServicesHealthy();
@@ -214,7 +241,11 @@ async function startConsole(noDaemon = false) {
214
241
  terminateProcessTree(pid);
215
242
  throw new Error('desktop console started but core services did not become healthy');
216
243
  }
244
+ const sessionId = await readWindowsSessionId(pid);
217
245
  console.log(`[ui-console] Started (PID: ${pid || 'unknown'})`);
246
+ if (sessionId === 0) {
247
+ console.warn('[ui-console] started in Session 0 (service/non-interactive). UI bridge is available, but desktop window will not be visible.');
248
+ }
218
249
  return;
219
250
  }
220
251