@web-auto/camo 0.1.2 → 0.1.4

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 (51) hide show
  1. package/README.md +137 -0
  2. package/package.json +7 -3
  3. package/scripts/check-file-size.mjs +80 -0
  4. package/scripts/file-size-policy.json +8 -0
  5. package/src/autoscript/action-providers/index.mjs +9 -0
  6. package/src/autoscript/action-providers/xhs/comments.mjs +412 -0
  7. package/src/autoscript/action-providers/xhs/common.mjs +77 -0
  8. package/src/autoscript/action-providers/xhs/detail.mjs +181 -0
  9. package/src/autoscript/action-providers/xhs/interaction.mjs +466 -0
  10. package/src/autoscript/action-providers/xhs/like-rules.mjs +57 -0
  11. package/src/autoscript/action-providers/xhs/persistence.mjs +167 -0
  12. package/src/autoscript/action-providers/xhs/search.mjs +174 -0
  13. package/src/autoscript/action-providers/xhs.mjs +133 -0
  14. package/src/autoscript/impact-engine.mjs +78 -0
  15. package/src/autoscript/runtime.mjs +1015 -0
  16. package/src/autoscript/schema.mjs +370 -0
  17. package/src/autoscript/xhs-unified-template.mjs +931 -0
  18. package/src/cli.mjs +190 -78
  19. package/src/commands/autoscript.mjs +1100 -0
  20. package/src/commands/browser.mjs +20 -4
  21. package/src/commands/container.mjs +401 -0
  22. package/src/commands/events.mjs +152 -0
  23. package/src/commands/lifecycle.mjs +17 -3
  24. package/src/commands/window.mjs +32 -1
  25. package/src/container/change-notifier.mjs +311 -0
  26. package/src/container/element-filter.mjs +143 -0
  27. package/src/container/index.mjs +3 -0
  28. package/src/container/runtime-core/checkpoint.mjs +195 -0
  29. package/src/container/runtime-core/index.mjs +21 -0
  30. package/src/container/runtime-core/operations/index.mjs +351 -0
  31. package/src/container/runtime-core/operations/selector-scripts.mjs +68 -0
  32. package/src/container/runtime-core/operations/tab-pool.mjs +544 -0
  33. package/src/container/runtime-core/operations/viewport.mjs +143 -0
  34. package/src/container/runtime-core/subscription.mjs +87 -0
  35. package/src/container/runtime-core/utils.mjs +94 -0
  36. package/src/container/runtime-core/validation.mjs +127 -0
  37. package/src/container/runtime-core.mjs +1 -0
  38. package/src/container/subscription-registry.mjs +459 -0
  39. package/src/core/actions.mjs +573 -0
  40. package/src/core/browser.mjs +270 -0
  41. package/src/core/index.mjs +53 -0
  42. package/src/core/utils.mjs +87 -0
  43. package/src/events/daemon-entry.mjs +33 -0
  44. package/src/events/daemon.mjs +80 -0
  45. package/src/events/progress-log.mjs +109 -0
  46. package/src/events/ws-server.mjs +239 -0
  47. package/src/lib/client.mjs +200 -0
  48. package/src/lifecycle/session-registry.mjs +8 -4
  49. package/src/lifecycle/session-watchdog.mjs +220 -0
  50. package/src/utils/browser-service.mjs +232 -9
  51. package/src/utils/help.mjs +28 -0
@@ -4,6 +4,7 @@ import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, che
4
4
  import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
5
5
  import { acquireLock, releaseLock, isLocked, getLockInfo, cleanupStaleLocks } from '../lifecycle/lock.mjs';
6
6
  import { registerSession, updateSession, getSessionInfo, unregisterSession, listRegisteredSessions, markSessionClosed, cleanupStaleSessions, recoverSession } from '../lifecycle/session-registry.mjs';
7
+ import { startSessionWatchdog, stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
7
8
 
8
9
  export async function handleStartCommand(args) {
9
10
  ensureCamoufox();
@@ -56,6 +57,7 @@ export async function handleStartCommand(args) {
56
57
  message: 'Session already running',
57
58
  url: existing.current_url,
58
59
  }, null, 2));
60
+ startSessionWatchdog(profileId);
59
61
  return;
60
62
  }
61
63
 
@@ -84,6 +86,7 @@ export async function handleStartCommand(args) {
84
86
  url: targetUrl,
85
87
  headless,
86
88
  });
89
+ startSessionWatchdog(profileId);
87
90
  }
88
91
  console.log(JSON.stringify(result, null, 2));
89
92
  }
@@ -92,10 +95,20 @@ export async function handleStopCommand(args) {
92
95
  await ensureBrowserService();
93
96
  const profileId = resolveProfileId(args, 1, getDefaultProfile);
94
97
  if (!profileId) throw new Error('Usage: camo stop [profileId]');
95
-
96
- const result = await callAPI('stop', { profileId });
97
- releaseLock(profileId);
98
- markSessionClosed(profileId);
98
+
99
+ let result = null;
100
+ let stopError = null;
101
+ try {
102
+ result = await callAPI('stop', { profileId });
103
+ } catch (err) {
104
+ stopError = err;
105
+ } finally {
106
+ stopSessionWatchdog(profileId);
107
+ releaseLock(profileId);
108
+ markSessionClosed(profileId);
109
+ }
110
+
111
+ if (stopError) throw stopError;
99
112
  console.log(JSON.stringify(result, null, 2));
100
113
  }
101
114
 
@@ -405,6 +418,7 @@ export async function handleShutdownCommand() {
405
418
  } catch {
406
419
  // Best effort cleanup
407
420
  }
421
+ stopSessionWatchdog(session.profileId);
408
422
  releaseLock(session.profileId);
409
423
  markSessionClosed(session.profileId);
410
424
  }
@@ -413,10 +427,12 @@ export async function handleShutdownCommand() {
413
427
  const registered = listRegisteredSessions();
414
428
  for (const reg of registered) {
415
429
  if (reg.status !== 'closed') {
430
+ stopSessionWatchdog(reg.profileId);
416
431
  markSessionClosed(reg.profileId);
417
432
  releaseLock(reg.profileId);
418
433
  }
419
434
  }
435
+ stopAllSessionWatchdogs();
420
436
 
421
437
  const result = await callAPI('service:shutdown', {});
422
438
  console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,401 @@
1
+ // Container commands - filter, watch, and subscription targets
2
+ import { getDomSnapshotByProfile, getSessionByProfile, getViewportByProfile } from '../utils/browser-service.mjs';
3
+ import { getDefaultProfile } from '../utils/config.mjs';
4
+ import { getChangeNotifier } from '../container/change-notifier.mjs';
5
+ import { createElementFilter } from '../container/element-filter.mjs';
6
+ import {
7
+ getRegisteredTargets,
8
+ initContainerSubscriptionDirectory,
9
+ listSubscriptionSets,
10
+ registerSubscriptionTargets,
11
+ } from '../container/subscription-registry.mjs';
12
+ import { safeAppendProgressEvent } from '../events/progress-log.mjs';
13
+
14
+ const notifier = getChangeNotifier();
15
+ const elementFilter = createElementFilter();
16
+
17
+ const VALUE_FLAGS = new Set([
18
+ '--profile',
19
+ '-p',
20
+ '--selector',
21
+ '-s',
22
+ '--throttle',
23
+ '-t',
24
+ '--source',
25
+ '--site',
26
+ ]);
27
+
28
+ function readFlagValue(args, names) {
29
+ for (let i = 0; i < args.length; i += 1) {
30
+ if (!names.includes(args[i])) continue;
31
+ const value = args[i + 1];
32
+ if (!value || String(value).startsWith('-')) return null;
33
+ return value;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function collectPositionals(args, startIndex = 2) {
39
+ const out = [];
40
+ for (let i = startIndex; i < args.length; i += 1) {
41
+ const arg = args[i];
42
+ if (!arg) continue;
43
+ if (VALUE_FLAGS.has(arg)) {
44
+ i += 1;
45
+ continue;
46
+ }
47
+ if (String(arg).startsWith('-')) continue;
48
+ out.push(arg);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ async function ensureSession(profileId) {
54
+ const session = await getSessionByProfile(profileId);
55
+ if (!session) {
56
+ throw new Error(`No active session for profile: ${profileId || 'default'}`);
57
+ }
58
+ return session;
59
+ }
60
+
61
+ function resolveWatchProfileAndSelectors(args) {
62
+ const explicitProfile = readFlagValue(args, ['--profile', '-p']);
63
+ const explicitSelector = readFlagValue(args, ['--selector', '-s']);
64
+ const positionals = collectPositionals(args);
65
+
66
+ if (explicitSelector) {
67
+ const profileId = explicitProfile || positionals[0] || getDefaultProfile();
68
+ return { profileId, selectors: [explicitSelector], source: 'manual' };
69
+ }
70
+
71
+ if (explicitProfile) {
72
+ return {
73
+ profileId: explicitProfile,
74
+ selectors: positionals,
75
+ source: positionals.length > 0 ? 'manual' : 'subscription',
76
+ };
77
+ }
78
+
79
+ if (positionals.length >= 2) {
80
+ return { profileId: positionals[0], selectors: positionals.slice(1), source: 'manual' };
81
+ }
82
+
83
+ if (positionals.length === 1) {
84
+ const candidateProfile = getRegisteredTargets(positionals[0])?.profile;
85
+ if (candidateProfile) {
86
+ return { profileId: positionals[0], selectors: [], source: 'subscription' };
87
+ }
88
+ return { profileId: getDefaultProfile(), selectors: [positionals[0]], source: 'manual' };
89
+ }
90
+
91
+ return { profileId: getDefaultProfile(), selectors: [], source: 'subscription' };
92
+ }
93
+
94
+ function resolveProfileAndSelectors(args) {
95
+ const explicitProfile = readFlagValue(args, ['--profile', '-p']);
96
+ const positionals = collectPositionals(args);
97
+ if (explicitProfile) {
98
+ return { profileId: explicitProfile, selectors: positionals };
99
+ }
100
+ if (positionals.length >= 2) {
101
+ return { profileId: positionals[0], selectors: positionals.slice(1) };
102
+ }
103
+ return { profileId: getDefaultProfile(), selectors: positionals };
104
+ }
105
+
106
+ function resolveListProfile(args) {
107
+ const explicitProfile = readFlagValue(args, ['--profile', '-p']);
108
+ if (explicitProfile) return explicitProfile;
109
+ const positionals = collectPositionals(args);
110
+ return positionals[0] || getDefaultProfile();
111
+ }
112
+
113
+ export async function handleContainerInitCommand(args) {
114
+ const source = readFlagValue(args, ['--source']);
115
+ const force = args.includes('--force');
116
+ const result = initContainerSubscriptionDirectory({
117
+ ...(source ? { containerLibraryRoot: source } : {}),
118
+ force,
119
+ });
120
+ console.log(JSON.stringify(result, null, 2));
121
+ }
122
+
123
+ export async function handleContainerSetsCommand(args) {
124
+ const site = readFlagValue(args, ['--site']);
125
+ const result = listSubscriptionSets({ ...(site ? { site } : {}) });
126
+ console.log(JSON.stringify(result, null, 2));
127
+ }
128
+
129
+ export async function handleContainerRegisterCommand(args) {
130
+ const explicitProfile = readFlagValue(args, ['--profile', '-p']);
131
+ const append = args.includes('--append');
132
+ const positionals = collectPositionals(args);
133
+
134
+ let profileId;
135
+ let setIds;
136
+ if (explicitProfile) {
137
+ profileId = explicitProfile;
138
+ setIds = positionals;
139
+ } else if (positionals.length >= 2) {
140
+ profileId = positionals[0];
141
+ setIds = positionals.slice(1);
142
+ } else {
143
+ profileId = getDefaultProfile();
144
+ setIds = positionals;
145
+ }
146
+
147
+ if (!profileId) {
148
+ throw new Error('Usage: camo container register [profileId] <setId...> [--append] [--profile <id>]');
149
+ }
150
+ if (!Array.isArray(setIds) || setIds.length === 0) {
151
+ throw new Error('Usage: camo container register [profileId] <setId...> [--append] [--profile <id>]');
152
+ }
153
+
154
+ const result = registerSubscriptionTargets(profileId, setIds, { append });
155
+ console.log(JSON.stringify(result, null, 2));
156
+ }
157
+
158
+ export async function handleContainerTargetsCommand(args) {
159
+ const explicitProfile = readFlagValue(args, ['--profile', '-p']);
160
+ const positionals = collectPositionals(args);
161
+ const profileId = explicitProfile || positionals[0] || null;
162
+ const result = getRegisteredTargets(profileId);
163
+ console.log(JSON.stringify(result, null, 2));
164
+ }
165
+
166
+ export async function handleContainerFilterCommand(args) {
167
+ const { profileId, selectors } = resolveProfileAndSelectors(args);
168
+ if (!profileId) {
169
+ throw new Error('Usage: camo container filter [profileId] <selector...> [--profile <id>]');
170
+ }
171
+ if (selectors.length === 0) {
172
+ throw new Error('Usage: camo container filter [profileId] <selector...> [--profile <id>]');
173
+ }
174
+
175
+ const session = await ensureSession(profileId);
176
+ const snapshot = await getDomSnapshotByProfile(session.profileId || profileId);
177
+
178
+ const matched = [];
179
+ for (const selector of selectors) {
180
+ const elements = notifier.findElements(snapshot, { css: selector });
181
+ matched.push(
182
+ ...elements.map((element) => ({
183
+ selector,
184
+ path: element.path,
185
+ tag: element.tag,
186
+ id: element.id,
187
+ classes: element.classes,
188
+ text: (element.textSnippet || element.text || '').slice(0, 80),
189
+ })),
190
+ );
191
+ }
192
+
193
+ console.log(JSON.stringify({ ok: true, profileId: session.profileId || profileId, count: matched.length, elements: matched }, null, 2));
194
+ }
195
+
196
+ export async function handleContainerWatchCommand(args) {
197
+ const watchRequest = resolveWatchProfileAndSelectors(args);
198
+ const profileId = watchRequest.profileId;
199
+ if (!profileId) {
200
+ throw new Error(
201
+ 'Usage: camo container watch [profileId] [--selector <css>|<selector...>] [--throttle ms] [--profile <id>]',
202
+ );
203
+ }
204
+
205
+ let selectors = Array.from(
206
+ new Set(
207
+ (watchRequest.selectors || [])
208
+ .map((item) => String(item || '').trim())
209
+ .filter(Boolean),
210
+ ),
211
+ );
212
+ let source = watchRequest.source;
213
+
214
+ if (selectors.length === 0) {
215
+ const registered = getRegisteredTargets(profileId)?.profile;
216
+ selectors = Array.from(
217
+ new Set(
218
+ (registered?.selectors || [])
219
+ .map((item) => item?.css)
220
+ .filter((css) => typeof css === 'string' && css.trim())
221
+ .map((css) => css.trim()),
222
+ ),
223
+ );
224
+ source = 'subscription';
225
+ }
226
+
227
+ if (selectors.length === 0) {
228
+ throw new Error(
229
+ `No selectors found for profile: ${profileId}. Use --selector <css> or run camo container register ${profileId} <setId...> first.`,
230
+ );
231
+ }
232
+
233
+ const session = await ensureSession(profileId);
234
+ const throttleRaw = readFlagValue(args, ['--throttle', '-t']);
235
+ const throttle = Math.max(100, Number(throttleRaw) || 500);
236
+
237
+ console.log(JSON.stringify({
238
+ ok: true,
239
+ message: `Watching ${selectors.length} selector(s) from ${source}`,
240
+ selectors,
241
+ profileId: session.profileId || profileId,
242
+ throttle,
243
+ }));
244
+ safeAppendProgressEvent({
245
+ source: 'container.watch',
246
+ mode: 'normal',
247
+ profileId: session.profileId || profileId,
248
+ event: 'container.watch.start',
249
+ payload: {
250
+ selectors,
251
+ throttle,
252
+ source,
253
+ },
254
+ });
255
+
256
+ const interval = setInterval(async () => {
257
+ try {
258
+ const snapshot = await getDomSnapshotByProfile(session.profileId || profileId);
259
+ notifier.processSnapshot(snapshot);
260
+ } catch (err) {
261
+ console.error(JSON.stringify({ ok: false, error: err?.message || String(err) }));
262
+ }
263
+ }, throttle);
264
+
265
+ const watchers = selectors.map((selector) => notifier.watch({ css: selector }, {
266
+ onAppear: (elements) => {
267
+ console.log(JSON.stringify({ event: 'appear', selector, count: elements.length, elements }));
268
+ safeAppendProgressEvent({
269
+ source: 'container.watch',
270
+ mode: 'normal',
271
+ profileId: session.profileId || profileId,
272
+ event: 'container.appear',
273
+ payload: { selector, count: elements.length },
274
+ });
275
+ },
276
+ onDisappear: (elements) => {
277
+ console.log(JSON.stringify({ event: 'disappear', selector, count: elements.length }));
278
+ safeAppendProgressEvent({
279
+ source: 'container.watch',
280
+ mode: 'normal',
281
+ profileId: session.profileId || profileId,
282
+ event: 'container.disappear',
283
+ payload: { selector, count: elements.length },
284
+ });
285
+ },
286
+ onChange: ({ appeared, disappeared }) => {
287
+ console.log(JSON.stringify({ event: 'change', selector, appeared: appeared.length, disappeared: disappeared.length }));
288
+ safeAppendProgressEvent({
289
+ source: 'container.watch',
290
+ mode: 'normal',
291
+ profileId: session.profileId || profileId,
292
+ event: 'container.change',
293
+ payload: { selector, appeared: appeared.length, disappeared: disappeared.length },
294
+ });
295
+ },
296
+ throttle,
297
+ }));
298
+
299
+ process.once('SIGINT', () => {
300
+ clearInterval(interval);
301
+ for (const stopWatch of watchers) stopWatch();
302
+ notifier.destroy();
303
+ console.log(JSON.stringify({ ok: true, message: 'Watch stopped' }));
304
+ safeAppendProgressEvent({
305
+ source: 'container.watch',
306
+ mode: 'normal',
307
+ profileId: session.profileId || profileId,
308
+ event: 'container.watch.stop',
309
+ payload: { selectors },
310
+ });
311
+ process.exit(0);
312
+ });
313
+ }
314
+
315
+ export async function handleContainerListCommand(args) {
316
+ const profileId = resolveListProfile(args);
317
+ if (!profileId) {
318
+ throw new Error('Usage: camo container list [profileId] [--profile <id>]');
319
+ }
320
+ const session = await ensureSession(profileId);
321
+
322
+ const snapshot = await getDomSnapshotByProfile(session.profileId || profileId);
323
+ const viewport = await getViewportByProfile(session.profileId || profileId);
324
+
325
+ const collectElements = (node, domPath = 'root') => {
326
+ const elements = [];
327
+ if (!node) return elements;
328
+
329
+ const rect = node.rect || node.bbox;
330
+ if (rect && viewport) {
331
+ const inViewport = elementFilter.isInViewport(rect, viewport);
332
+ const visibilityRatio = elementFilter.getVisibilityRatio(rect, viewport);
333
+ if (inViewport && visibilityRatio > 0.1) {
334
+ elements.push({
335
+ path: domPath,
336
+ tag: node.tag,
337
+ id: node.id,
338
+ classes: node.classes?.slice(0, 3),
339
+ visibilityRatio: Math.round(visibilityRatio * 100) / 100,
340
+ rect: { x: rect.left || rect.x, y: rect.top || rect.y, w: rect.width, h: rect.height },
341
+ });
342
+ }
343
+ }
344
+
345
+ if (Array.isArray(node.children)) {
346
+ for (let i = 0; i < node.children.length; i += 1) {
347
+ elements.push(...collectElements(node.children[i], `${domPath}/${i}`));
348
+ }
349
+ }
350
+
351
+ return elements;
352
+ };
353
+
354
+ const elements = collectElements(snapshot);
355
+ console.log(JSON.stringify({
356
+ ok: true,
357
+ profileId: session.profileId || profileId,
358
+ viewport,
359
+ count: elements.length,
360
+ elements: elements.slice(0, 50),
361
+ }, null, 2));
362
+ }
363
+
364
+ export async function handleContainerCommand(args) {
365
+ const sub = args[1];
366
+
367
+ switch (sub) {
368
+ case 'init':
369
+ return handleContainerInitCommand(args);
370
+ case 'sets':
371
+ return handleContainerSetsCommand(args);
372
+ case 'register':
373
+ return handleContainerRegisterCommand(args);
374
+ case 'targets':
375
+ return handleContainerTargetsCommand(args);
376
+ case 'filter':
377
+ return handleContainerFilterCommand(args);
378
+ case 'watch':
379
+ return handleContainerWatchCommand(args);
380
+ case 'list':
381
+ return handleContainerListCommand(args);
382
+ default:
383
+ console.log(`Usage: camo container <init|sets|register|targets|filter|watch|list> [options]
384
+
385
+ Commands:
386
+ init [--source <container-library-dir>] [--force] Initialize subscription directory and migrate container sets
387
+ sets [--site <siteKey>] List migrated subscription sets
388
+ register [profileId] <setId...> [--append] Register subscription targets for profile
389
+ targets [profileId] Show registered targets
390
+ filter [profileId] <selector...> Filter DOM elements by CSS selector
391
+ watch [profileId] [--selector <css>] [--throttle <ms>] Watch for element changes (defaults to registered selectors)
392
+ list [profileId] List visible elements in viewport
393
+
394
+ Options:
395
+ --profile, -p <id> Profile to use
396
+ --selector, -s <css> Selector for watch
397
+ --throttle, -t <ms> Poll interval for watch (default: 500)
398
+ --site <siteKey> Filter set list by site
399
+ `);
400
+ }
401
+ }
@@ -0,0 +1,152 @@
1
+ import { createProgressWsServer } from '../events/ws-server.mjs';
2
+ import { getProgressEventsFile, readRecentProgressEvents, safeAppendProgressEvent } from '../events/progress-log.mjs';
3
+ import { ensureProgressEventDaemon } from '../events/daemon.mjs';
4
+
5
+ function readFlagValue(args, names) {
6
+ for (let i = 0; i < args.length; i += 1) {
7
+ if (!names.includes(args[i])) continue;
8
+ const value = args[i + 1];
9
+ if (!value || String(value).startsWith('-')) return null;
10
+ return value;
11
+ }
12
+ return null;
13
+ }
14
+
15
+ function hasFlag(args, name) {
16
+ return args.includes(name);
17
+ }
18
+
19
+ function buildQuery(args) {
20
+ const profileId = readFlagValue(args, ['--profile', '-p']);
21
+ const runId = readFlagValue(args, ['--run-id']);
22
+ const mode = readFlagValue(args, ['--mode']);
23
+ const events = readFlagValue(args, ['--events']);
24
+ const replay = Math.max(0, Number(readFlagValue(args, ['--replay']) ?? 50) || 50);
25
+ const qs = new URLSearchParams();
26
+ if (profileId) qs.set('profileId', profileId);
27
+ if (runId) qs.set('runId', runId);
28
+ if (mode) qs.set('mode', mode);
29
+ if (events) qs.set('events', events);
30
+ qs.set('replay', String(replay));
31
+ return qs;
32
+ }
33
+
34
+ async function handleEventsServe(args) {
35
+ const host = readFlagValue(args, ['--host']) || '127.0.0.1';
36
+ const port = Math.max(1, Number(readFlagValue(args, ['--port']) || 7788) || 7788);
37
+ const pollMs = Math.max(80, Number(readFlagValue(args, ['--poll-ms']) || 220) || 220);
38
+ const fromStart = hasFlag(args, '--from-start');
39
+
40
+ const server = createProgressWsServer({ host, port, pollMs, fromStart });
41
+ const info = await server.start();
42
+ console.log(JSON.stringify({
43
+ ok: true,
44
+ command: 'events.serve',
45
+ ...info,
46
+ message: 'Progress WS server started. Press Ctrl+C to stop.',
47
+ }, null, 2));
48
+
49
+ const stop = async (reason = 'signal_interrupt') => {
50
+ await server.stop();
51
+ console.log(JSON.stringify({ ok: true, event: 'events.serve.stop', reason }));
52
+ process.exit(0);
53
+ };
54
+ process.once('SIGINT', () => {
55
+ stop('SIGINT');
56
+ });
57
+ process.once('SIGTERM', () => {
58
+ stop('SIGTERM');
59
+ });
60
+
61
+ await new Promise(() => {});
62
+ }
63
+
64
+ async function handleEventsTail(args) {
65
+ const host = readFlagValue(args, ['--host']) || '127.0.0.1';
66
+ const port = Math.max(1, Number(readFlagValue(args, ['--port']) || 7788) || 7788);
67
+ await ensureProgressEventDaemon({ host, port });
68
+ const qs = buildQuery(args);
69
+ const wsUrl = `ws://${host}:${port}/events?${qs.toString()}`;
70
+ if (typeof WebSocket !== 'function') {
71
+ throw new Error('Global WebSocket is unavailable in this Node runtime');
72
+ }
73
+
74
+ const socket = new WebSocket(wsUrl);
75
+ socket.addEventListener('open', () => {
76
+ console.log(JSON.stringify({ ok: true, command: 'events.tail', wsUrl }));
77
+ });
78
+ socket.addEventListener('message', (event) => {
79
+ const text = typeof event.data === 'string' ? event.data : String(event.data);
80
+ console.log(text);
81
+ });
82
+ socket.addEventListener('close', () => {
83
+ process.exit(0);
84
+ });
85
+ socket.addEventListener('error', (err) => {
86
+ console.error(JSON.stringify({ ok: false, command: 'events.tail', wsUrl, error: err?.message || String(err) }));
87
+ process.exit(1);
88
+ });
89
+ process.once('SIGINT', () => {
90
+ socket.close();
91
+ });
92
+
93
+ await new Promise(() => {});
94
+ }
95
+
96
+ function handleEventsRecent(args) {
97
+ const limit = Math.max(1, Number(readFlagValue(args, ['--limit', '-n']) || 50) || 50);
98
+ const rows = readRecentProgressEvents(limit);
99
+ console.log(JSON.stringify({
100
+ ok: true,
101
+ command: 'events.recent',
102
+ file: getProgressEventsFile(),
103
+ count: rows.length,
104
+ events: rows,
105
+ }, null, 2));
106
+ }
107
+
108
+ function handleEventsEmit(args) {
109
+ const eventName = readFlagValue(args, ['--event']) || 'manual.emit';
110
+ const mode = readFlagValue(args, ['--mode']) || 'normal';
111
+ const profileId = readFlagValue(args, ['--profile', '-p']) || null;
112
+ const runId = readFlagValue(args, ['--run-id']) || null;
113
+ const payloadRaw = readFlagValue(args, ['--payload']) || '{}';
114
+ let payload = null;
115
+ try {
116
+ payload = JSON.parse(payloadRaw);
117
+ } catch {
118
+ payload = { raw: payloadRaw };
119
+ }
120
+ const appended = safeAppendProgressEvent({
121
+ source: 'events.emit',
122
+ mode,
123
+ profileId,
124
+ runId,
125
+ event: eventName,
126
+ payload,
127
+ });
128
+ console.log(JSON.stringify({ ok: Boolean(appended), command: 'events.emit', event: appended }, null, 2));
129
+ }
130
+
131
+ export async function handleEventsCommand(args) {
132
+ const sub = args[1];
133
+ switch (sub) {
134
+ case 'serve':
135
+ return handleEventsServe(args);
136
+ case 'tail':
137
+ return handleEventsTail(args);
138
+ case 'recent':
139
+ return handleEventsRecent(args);
140
+ case 'emit':
141
+ return handleEventsEmit(args);
142
+ default:
143
+ console.log(`Usage: camo events <serve|tail|recent|emit> [options]
144
+
145
+ Commands:
146
+ serve [--host 127.0.0.1] [--port 7788] [--poll-ms 220] [--from-start]
147
+ tail [--host 127.0.0.1] [--port 7788] [--profile <id>] [--run-id <id>] [--mode <normal|autoscript>] [--events e1,e2] [--replay 50]
148
+ recent [--limit 50]
149
+ emit --event <name> [--mode <normal|autoscript>] [--profile <id>] [--run-id <id>] [--payload '{"k":"v"}']
150
+ `);
151
+ }
152
+ }
@@ -10,6 +10,7 @@ import {
10
10
  getSessionInfo, unregisterSession, markSessionClosed, cleanupStaleSessions,
11
11
  listRegisteredSessions, registerSession, updateSession
12
12
  } from '../lifecycle/session-registry.mjs';
13
+ import { stopAllSessionWatchdogs, stopSessionWatchdog } from '../lifecycle/session-watchdog.mjs';
13
14
 
14
15
  export async function handleCleanupCommand(args) {
15
16
  const sub = args[1];
@@ -36,14 +37,21 @@ export async function handleCleanupCommand(args) {
36
37
  const sessions = Array.isArray(status?.sessions) ? status.sessions : [];
37
38
 
38
39
  for (const session of sessions) {
40
+ let stopError = null;
39
41
  try {
40
42
  await callAPI('stop', { profileId: session.profileId });
43
+ } catch (err) {
44
+ stopError = err;
45
+ } finally {
46
+ stopSessionWatchdog(session.profileId);
41
47
  releaseLock(session.profileId);
42
48
  markSessionClosed(session.profileId);
43
- results.push({ profileId: session.profileId, ok: true });
44
- } catch (err) {
45
- results.push({ profileId: session.profileId, ok: false, error: err.message });
46
49
  }
50
+ results.push(
51
+ stopError
52
+ ? { profileId: session.profileId, ok: false, error: stopError.message }
53
+ : { profileId: session.profileId, ok: true },
54
+ );
47
55
  }
48
56
  } catch {}
49
57
  }
@@ -51,6 +59,7 @@ export async function handleCleanupCommand(args) {
51
59
  // Cleanup stale locks and sessions
52
60
  const cleanedLocks = cleanupStaleLocks();
53
61
  const cleanedSessions = cleanupStaleSessions();
62
+ stopAllSessionWatchdogs();
54
63
 
55
64
  console.log(JSON.stringify({
56
65
  ok: true,
@@ -74,6 +83,7 @@ export async function handleCleanupCommand(args) {
74
83
  } catch {}
75
84
  }
76
85
 
86
+ stopSessionWatchdog(profileId);
77
87
  releaseLock(profileId);
78
88
  markSessionClosed(profileId);
79
89
  console.log(JSON.stringify({ ok: true, profileId }, null, 2));
@@ -86,11 +96,13 @@ export async function handleForceStopCommand(args) {
86
96
 
87
97
  try {
88
98
  const result = await callAPI('stop', { profileId, force: true });
99
+ stopSessionWatchdog(profileId);
89
100
  releaseLock(profileId);
90
101
  markSessionClosed(profileId);
91
102
  console.log(JSON.stringify({ ok: true, profileId, ...result }, null, 2));
92
103
  } catch (err) {
93
104
  // Even if stop fails, cleanup local state
105
+ stopSessionWatchdog(profileId);
94
106
  releaseLock(profileId);
95
107
  markSessionClosed(profileId);
96
108
  console.log(JSON.stringify({ ok: true, profileId, warning: 'Session stopped locally but remote stop failed: ' + err.message }, null, 2));
@@ -204,6 +216,7 @@ export async function handleRecoverCommand(args) {
204
216
 
205
217
  if (!serviceUp) {
206
218
  // Service is down - session cannot be recovered, clean up
219
+ stopSessionWatchdog(profileId);
207
220
  unregisterSession(profileId);
208
221
  releaseLock(profileId);
209
222
  console.log(JSON.stringify({
@@ -240,6 +253,7 @@ export async function handleRecoverCommand(args) {
240
253
  }, null, 2));
241
254
  } else {
242
255
  // Session not in browser service - clean up
256
+ stopSessionWatchdog(profileId);
243
257
  unregisterSession(profileId);
244
258
  releaseLock(profileId);
245
259
  console.log(JSON.stringify({