@shawnowen/comet-mcp 2.3.1 → 2.4.2

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 +97 -19
  2. package/dist/alert-dispatcher.d.ts +23 -0
  3. package/dist/alert-dispatcher.js +101 -0
  4. package/dist/binding-reaper.d.ts +46 -0
  5. package/dist/binding-reaper.js +73 -0
  6. package/dist/bound-session.d.ts +23 -0
  7. package/dist/bound-session.js +119 -0
  8. package/dist/bridge-config.d.ts +6 -0
  9. package/dist/bridge-config.js +78 -0
  10. package/dist/cdp-client.d.ts +40 -4
  11. package/dist/cdp-client.js +502 -155
  12. package/dist/comet-ai.d.ts +15 -0
  13. package/dist/comet-ai.js +114 -38
  14. package/dist/delegate-binding.d.ts +19 -0
  15. package/dist/delegate-binding.js +73 -0
  16. package/dist/http-server.js +2188 -47
  17. package/dist/index.js +3545 -788
  18. package/dist/observer.d.ts +47 -0
  19. package/dist/observer.js +516 -0
  20. package/dist/project-config.d.ts +46 -0
  21. package/dist/project-config.js +166 -0
  22. package/dist/session-registry.d.ts +57 -0
  23. package/dist/session-registry.js +500 -0
  24. package/dist/sidecar-artifacts.d.ts +49 -0
  25. package/dist/sidecar-artifacts.js +146 -0
  26. package/dist/snapshot-capture.d.ts +3 -0
  27. package/dist/snapshot-capture.js +91 -0
  28. package/dist/tab-group-archive.js +3 -1
  29. package/dist/tab-groups.d.ts +28 -1
  30. package/dist/tab-groups.js +205 -3
  31. package/dist/types.d.ts +237 -0
  32. package/dist/window-bindings.d.ts +160 -0
  33. package/dist/window-bindings.js +561 -0
  34. package/extension/background.js +1577 -300
  35. package/extension/icons/icon.svg +9 -0
  36. package/extension/icons/icon128.png +0 -0
  37. package/extension/icons/icon16.png +0 -0
  38. package/extension/icons/icon48.png +0 -0
  39. package/extension/manifest.json +34 -4
  40. package/extension/perplexity-capability-manifest.json +1181 -0
  41. package/extension/perplexity-capability-manifest.schema.json +142 -0
  42. package/extension/session-logic.js +3054 -0
  43. package/extension/session-manager.html +311 -0
  44. package/extension/sidepanel.css +5338 -528
  45. package/extension/sidepanel.html +282 -2
  46. package/extension/sidepanel.js +10604 -950
  47. package/extension/window-policy.js +162 -0
  48. package/package.json +10 -7
  49. package/vendor/lifecycle-mcp-adapter.mjs +103 -0
  50. package/vendor/lifecycle-metadata.mjs +252 -0
  51. package/vendor/readiness-report.mjs +742 -0
  52. package/dist/cdp-client.d.ts.map +0 -1
  53. package/dist/cdp-client.js.map +0 -1
  54. package/dist/comet-ai.d.ts.map +0 -1
  55. package/dist/comet-ai.js.map +0 -1
  56. package/dist/http-server.d.ts.map +0 -1
  57. package/dist/http-server.js.map +0 -1
  58. package/dist/index.d.ts.map +0 -1
  59. package/dist/index.js.map +0 -1
  60. package/dist/tab-group-archive.d.ts.map +0 -1
  61. package/dist/tab-group-archive.js.map +0 -1
  62. package/dist/tab-groups.d.ts.map +0 -1
  63. package/dist/tab-groups.js.map +0 -1
  64. package/dist/types.d.ts.map +0 -1
  65. package/dist/types.js.map +0 -1
@@ -0,0 +1,162 @@
1
+ // Shared extension window policy for MV3 service worker and sidepanel contexts.
2
+ (function installCometWindowPolicy(globalScope) {
3
+ "use strict";
4
+
5
+ const TOP_DISPLAY_FULLSCREEN_WINDOW = Object.freeze({
6
+ left: 0,
7
+ top: -1440,
8
+ width: 2560,
9
+ height: 1440,
10
+ state: "normal",
11
+ focused: false,
12
+ });
13
+
14
+ const LOCKED_FULLSCREEN_DISPLAY_POLICY = Object.freeze({
15
+ mode: "locked",
16
+ topDisplayBounds: Object.freeze({
17
+ left: TOP_DISPLAY_FULLSCREEN_WINDOW.left,
18
+ top: TOP_DISPLAY_FULLSCREEN_WINDOW.top,
19
+ width: TOP_DISPLAY_FULLSCREEN_WINDOW.width,
20
+ height: TOP_DISPLAY_FULLSCREEN_WINDOW.height,
21
+ }),
22
+ windowState: TOP_DISPLAY_FULLSCREEN_WINDOW.state,
23
+ nativeFullscreen: false,
24
+ });
25
+
26
+ const LOCKED_GROUPS_PER_WINDOW_POLICY = Object.freeze({
27
+ value: 1,
28
+ mode: "hard",
29
+ locked: true,
30
+ });
31
+
32
+ const managedWindowIds = new Set();
33
+
34
+ function registerManagedWindow(windowId) {
35
+ if (windowId) managedWindowIds.add(windowId);
36
+ }
37
+
38
+ function isManagedWindow(windowId) {
39
+ return Boolean(windowId && managedWindowIds.has(windowId));
40
+ }
41
+
42
+ async function createTopDisplayFullscreenWindow(createData = {}) {
43
+ const win = await chrome.windows.create({
44
+ ...createData,
45
+ left: TOP_DISPLAY_FULLSCREEN_WINDOW.left,
46
+ top: TOP_DISPLAY_FULLSCREEN_WINDOW.top,
47
+ width: TOP_DISPLAY_FULLSCREEN_WINDOW.width,
48
+ height: TOP_DISPLAY_FULLSCREEN_WINDOW.height,
49
+ focused: TOP_DISPLAY_FULLSCREEN_WINDOW.focused,
50
+ });
51
+
52
+ if (win?.id) {
53
+ registerManagedWindow(win.id);
54
+ await chrome.windows.update(win.id, { state: "normal" }).catch(() => {});
55
+ await chrome.windows
56
+ .update(win.id, {
57
+ left: TOP_DISPLAY_FULLSCREEN_WINDOW.left,
58
+ top: TOP_DISPLAY_FULLSCREEN_WINDOW.top,
59
+ width: TOP_DISPLAY_FULLSCREEN_WINDOW.width,
60
+ height: TOP_DISPLAY_FULLSCREEN_WINDOW.height,
61
+ focused: TOP_DISPLAY_FULLSCREEN_WINDOW.focused,
62
+ })
63
+ .catch(() => {});
64
+ await chrome.windows.update(win.id, { state: "normal" }).catch(() => {});
65
+ }
66
+
67
+ return win;
68
+ }
69
+
70
+ async function enforceTopDisplayFullscreenWindow(windowId) {
71
+ if (!windowId) return;
72
+ await chrome.windows.update(windowId, { state: "normal" }).catch(() => {});
73
+ await chrome.windows
74
+ .update(windowId, {
75
+ left: TOP_DISPLAY_FULLSCREEN_WINDOW.left,
76
+ top: TOP_DISPLAY_FULLSCREEN_WINDOW.top,
77
+ width: TOP_DISPLAY_FULLSCREEN_WINDOW.width,
78
+ height: TOP_DISPLAY_FULLSCREEN_WINDOW.height,
79
+ focused: TOP_DISPLAY_FULLSCREEN_WINDOW.focused,
80
+ })
81
+ .catch(() => {});
82
+ }
83
+
84
+ async function moveGroupToDedicatedFullscreenWindow(group) {
85
+ const groupTabs = await chrome.tabs.query({ groupId: group.id });
86
+ if (!groupTabs.length) {
87
+ return { moved: false, groupId: group.id, group };
88
+ }
89
+
90
+ const win = await createTopDisplayFullscreenWindow({ tabId: groupTabs[0].id });
91
+ if (groupTabs.length > 1) {
92
+ await chrome.tabs.move(
93
+ groupTabs.slice(1).map((tab) => tab.id),
94
+ { windowId: win.id, index: -1 }
95
+ );
96
+ }
97
+
98
+ const regroupedId = await chrome.tabs.group({ tabIds: groupTabs.map((tab) => tab.id) });
99
+ await chrome.tabGroups.update(regroupedId, {
100
+ title: group.title || "",
101
+ color: group.color || "grey",
102
+ collapsed: group.collapsed || false,
103
+ });
104
+ const regrouped = await chrome.tabGroups.get(regroupedId);
105
+ await enforceTopDisplayFullscreenWindow(regrouped.windowId);
106
+ return { moved: true, groupId: regrouped.id, group: regrouped };
107
+ }
108
+
109
+ async function ensureOneGroupPerWindow(changedGroupId = null, options = {}) {
110
+ const groups = await chrome.tabGroups.query({});
111
+ if (changedGroupId && options.markChangedWindowManaged) {
112
+ const changedGroup = groups.find((group) => group.id === changedGroupId);
113
+ if (changedGroup?.windowId) registerManagedWindow(changedGroup.windowId);
114
+ }
115
+
116
+ const groupsByWindow = new Map();
117
+ for (const group of groups) {
118
+ if (!isManagedWindow(group.windowId)) continue;
119
+ const existing = groupsByWindow.get(group.windowId) || [];
120
+ existing.push(group);
121
+ groupsByWindow.set(group.windowId, existing);
122
+ }
123
+
124
+ let tracked = null;
125
+ for (const [windowId, windowGroups] of groupsByWindow.entries()) {
126
+ if (windowGroups.length <= LOCKED_GROUPS_PER_WINDOW_POLICY.value) {
127
+ await enforceTopDisplayFullscreenWindow(windowId);
128
+ if (changedGroupId && windowGroups[0]?.id === changedGroupId) {
129
+ tracked = { moved: false, groupId: windowGroups[0].id, group: windowGroups[0] };
130
+ }
131
+ continue;
132
+ }
133
+
134
+ const keepGroup =
135
+ windowGroups.find((group) => group.id !== changedGroupId) || windowGroups[0];
136
+ for (const group of windowGroups) {
137
+ if (group.id === keepGroup.id) continue;
138
+ const result = await moveGroupToDedicatedFullscreenWindow(group);
139
+ if (group.id === changedGroupId) tracked = result;
140
+ }
141
+ await enforceTopDisplayFullscreenWindow(keepGroup.windowId);
142
+ }
143
+
144
+ if (changedGroupId && !tracked) {
145
+ const group = await chrome.tabGroups.get(changedGroupId).catch(() => null);
146
+ if (group) tracked = { moved: false, groupId: group.id, group };
147
+ }
148
+
149
+ return tracked;
150
+ }
151
+
152
+ globalThis.CometWindowPolicy = Object.freeze({
153
+ TOP_DISPLAY_FULLSCREEN_WINDOW,
154
+ LOCKED_FULLSCREEN_DISPLAY_POLICY,
155
+ LOCKED_GROUPS_PER_WINDOW_POLICY,
156
+ createTopDisplayFullscreenWindow,
157
+ enforceTopDisplayFullscreenWindow,
158
+ ensureOneGroupPerWindow,
159
+ isManagedWindow,
160
+ registerManagedWindow,
161
+ });
162
+ })(globalThis);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shawnowen/comet-mcp",
3
- "version": "2.3.1",
3
+ "version": "2.4.2",
4
4
  "description": "MCP Server that gives Claude Code superpowers with Perplexity Comet browser - agentic web browsing, deep research, and real-time monitoring",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -8,12 +8,15 @@
8
8
  "comet-mcp": "dist/index.js"
9
9
  },
10
10
  "files": [
11
- "dist",
12
- "extension",
11
+ "dist/**",
12
+ "vendor/**",
13
+ "extension/**",
13
14
  "README.md",
14
- "LICENSE"
15
+ "LICENSE",
16
+ "!**/*.map"
15
17
  ],
16
18
  "scripts": {
19
+ "prebuild": "node -e \"const fs=require('fs'),p=require('path');const src=p.resolve('../scripts');const dst=p.resolve('vendor');fs.mkdirSync(dst,{recursive:true});for(const f of ['lifecycle-mcp-adapter.mjs','lifecycle-metadata.mjs','readiness-report.mjs'])fs.copyFileSync(p.join(src,f),p.join(dst,f));console.log('vendored lifecycle scripts -> vendor/');\"",
17
20
  "build": "tsc",
18
21
  "test": "vitest run",
19
22
  "prepublishOnly": "npm run build",
@@ -37,11 +40,11 @@
37
40
  "license": "MIT",
38
41
  "repository": {
39
42
  "type": "git",
40
- "url": "git+https://github.com/ShawnOwen/Comet-Bridge.git"
43
+ "url": "git+https://github.com/EQUAStart/equa-comet-browser-control.git"
41
44
  },
42
- "homepage": "https://github.com/ShawnOwen/Comet-Bridge#readme",
45
+ "homepage": "https://github.com/EQUAStart/equa-comet-browser-control#readme",
43
46
  "bugs": {
44
- "url": "https://github.com/ShawnOwen/Comet-Bridge/issues"
47
+ "url": "https://github.com/EQUAStart/equa-comet-browser-control/issues"
45
48
  },
46
49
  "engines": {
47
50
  "node": ">=18.0.0"
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP lifecycle adapter (Spec 070, S070-T015).
4
+ *
5
+ * Ensures MCP tool calls emit the same lifecycle metadata as the CLI path
6
+ * (cursor-comet.mjs), satisfying AC-3, AC-5, AC-6 parity requirements.
7
+ *
8
+ * MCP servers import this adapter to wrap tool executions with lifecycle
9
+ * events that produce identical fields to the CLI path. Both paths use
10
+ * the shared lifecycle-metadata.mjs module.
11
+ *
12
+ * Usage from comet-mcp or any MCP handler:
13
+ * import { wrapMCPToolCall, createMCPLifecycleEnvelope } from '../scripts/lifecycle-mcp-adapter.mjs';
14
+ *
15
+ * // Option A: wrap an entire tool call
16
+ * const result = await wrapMCPToolCall({ taskThreadId, agentId, toolName }, async (envelope) => {
17
+ * // ... tool execution ...
18
+ * return toolResult;
19
+ * });
20
+ *
21
+ * // Option B: manual envelope management
22
+ * const envelope = createMCPLifecycleEnvelope({ taskThreadId });
23
+ * emitLifecycleEvent('start', envelope);
24
+ * // ... tool execution ...
25
+ * transitionEnvelope(envelope, 'completed');
26
+ * emitLifecycleEvent('complete', envelope);
27
+ *
28
+ * @module lifecycle-mcp-adapter
29
+ */
30
+
31
+ import {
32
+ createLifecycleEnvelope,
33
+ transitionEnvelope,
34
+ emitLifecycleEvent,
35
+ lifecycleEnvVars,
36
+ validateEnvelope,
37
+ } from "./lifecycle-metadata.mjs";
38
+
39
+ /**
40
+ * Create a lifecycle envelope pre-configured for the MCP route.
41
+ * Equivalent to what cursor-comet.mjs creates for CLI, but with route='mcp'.
42
+ *
43
+ * @param {object} opts
44
+ * @param {string} [opts.taskThreadId]
45
+ * @param {string} [opts.agentId]
46
+ * @param {string} [opts.runId]
47
+ * @param {string} [opts.toolName] - MCP tool name (stored as workflowId for traceability)
48
+ * @param {boolean} [opts.fallbackUsed] - True if this is a fallback from failed CLI/HTTP path
49
+ * @returns {object} Lifecycle envelope with route='mcp'
50
+ */
51
+ export function createMCPLifecycleEnvelope(opts = {}) {
52
+ return createLifecycleEnvelope({
53
+ ...opts,
54
+ route: "mcp",
55
+ workflowId: opts.toolName || opts.workflowId,
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Wrap an MCP tool call with lifecycle events.
61
+ * Creates an envelope, emits start, runs the handler, and emits complete/fail.
62
+ *
63
+ * The handler receives the envelope and may update it (e.g. set auditSessionId).
64
+ *
65
+ * @param {object} opts - Lifecycle identity options
66
+ * @param {string} [opts.taskThreadId]
67
+ * @param {string} [opts.agentId]
68
+ * @param {string} [opts.runId]
69
+ * @param {string} [opts.toolName]
70
+ * @param {boolean} [opts.fallbackUsed]
71
+ * @param {(envelope: object) => Promise<any>} handler - Tool execution function
72
+ * @returns {Promise<{ result: any, envelope: object }>}
73
+ */
74
+ export async function wrapMCPToolCall(opts, handler) {
75
+ const envelope = createMCPLifecycleEnvelope(opts);
76
+ emitLifecycleEvent("start", envelope, { persist: true });
77
+
78
+ try {
79
+ const result = await handler(envelope);
80
+ transitionEnvelope(envelope, "completed");
81
+ emitLifecycleEvent("complete", envelope, { persist: true });
82
+ return { result, envelope };
83
+ } catch (err) {
84
+ const reason = err instanceof Error ? err.message : String(err);
85
+ transitionEnvelope(envelope, "failed", { failureReason: reason });
86
+ emitLifecycleEvent("fail", envelope, { persist: true });
87
+ throw err;
88
+ }
89
+ }
90
+
91
+ // Re-export shared primitives for convenience
92
+ export {
93
+ createLifecycleEnvelope,
94
+ transitionEnvelope,
95
+ emitLifecycleEvent,
96
+ lifecycleEnvVars,
97
+ validateEnvelope,
98
+ };
99
+
100
+ export default {
101
+ createMCPLifecycleEnvelope,
102
+ wrapMCPToolCall,
103
+ };
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Shared lifecycle metadata emitter (Spec 070, S070-T015).
4
+ *
5
+ * Both the CLI path (cursor-comet.mjs) and MCP path (lifecycle-mcp-adapter)
6
+ * use this module to create and emit lifecycle envelopes, ensuring both
7
+ * routes produce equivalent fields per integration-contract.md §1.
8
+ *
9
+ * @module lifecycle-metadata
10
+ */
11
+
12
+ import { randomUUID } from "crypto";
13
+ import { writeFileSync, mkdirSync, existsSync } from "fs";
14
+ import { join } from "path";
15
+ import { recordTelemetryEvent } from "./readiness-report.mjs";
16
+
17
+ /** Canonical lifecycle fields per integration-contract.md §1.1 / §1.2 */
18
+ const REQUIRED_FIELDS = ["taskThreadId", "runId", "status", "startedAt"];
19
+ const OPTIONAL_FIELDS = [
20
+ "agentId",
21
+ "tabGroupId",
22
+ "auditSessionId",
23
+ "workflowId",
24
+ "completedAt",
25
+ "failureReason",
26
+ "route",
27
+ "fallbackUsed",
28
+ ];
29
+
30
+ /**
31
+ * Create a lifecycle envelope with the canonical fields.
32
+ * Missing required fields are auto-filled with sensible defaults.
33
+ *
34
+ * @param {object} opts
35
+ * @param {string} opts.taskThreadId - Task-thread identifier.
36
+ * @param {string} [opts.agentId] - Agent identity.
37
+ * @param {string} [opts.runId] - Run id (auto-generated if omitted).
38
+ * @param {'pending'|'running'|'completed'|'aborted'|'failed'} [opts.status] - Initial status.
39
+ * @param {'mcp'|'cli'|'http'} [opts.route] - Execution channel.
40
+ * @param {boolean} [opts.fallbackUsed] - Whether a fallback path was used.
41
+ * @param {string} [opts.tabGroupId]
42
+ * @param {string} [opts.auditSessionId]
43
+ * @param {string} [opts.workflowId]
44
+ * @param {boolean} [opts.deferred] - If true, start as 'pending' instead of 'running'.
45
+ * @returns {object} Lifecycle envelope
46
+ */
47
+ export function createLifecycleEnvelope(opts = {}) {
48
+ const now = new Date().toISOString();
49
+ return {
50
+ taskThreadId: opts.taskThreadId || process.env.COMET_TASK_GROUP || "unknown",
51
+ agentId: opts.agentId || process.env.COMET_AGENT_ID || undefined,
52
+ runId: opts.runId || randomUUID(),
53
+ status: opts.deferred ? "pending" : opts.status || "running",
54
+ startedAt: opts.startedAt || now,
55
+ route: opts.route || "cli",
56
+ fallbackUsed: opts.fallbackUsed ?? false,
57
+ tabGroupId: opts.tabGroupId || undefined,
58
+ auditSessionId: opts.auditSessionId || undefined,
59
+ workflowId: opts.workflowId || undefined,
60
+ completedAt: undefined,
61
+ failureReason: undefined,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Transition a lifecycle envelope to a new status, enforcing the
67
+ * transition table from integration-contract.md §2.3.
68
+ *
69
+ * @param {object} envelope - Existing lifecycle envelope.
70
+ * @param {'running'|'completed'|'aborted'|'failed'} newStatus
71
+ * @param {{ failureReason?: string }} [meta]
72
+ * @returns {object} Updated envelope (mutated in place + returned)
73
+ */
74
+ export function transitionEnvelope(envelope, newStatus, meta = {}) {
75
+ const ALLOWED = {
76
+ pending: new Set(["running", "aborted", "failed"]),
77
+ running: new Set(["completed", "aborted", "failed"]),
78
+ completed: new Set(),
79
+ aborted: new Set(),
80
+ failed: new Set(),
81
+ };
82
+
83
+ const allowed = ALLOWED[envelope.status];
84
+ if (!allowed || !allowed.has(newStatus)) {
85
+ throw new Error(
86
+ `Invalid lifecycle transition for run ${envelope.runId}: ${envelope.status} → ${newStatus}`
87
+ );
88
+ }
89
+
90
+ envelope.status = newStatus;
91
+ if (newStatus === "completed" || newStatus === "aborted" || newStatus === "failed") {
92
+ envelope.completedAt = new Date().toISOString();
93
+ }
94
+ if (meta.failureReason) {
95
+ envelope.failureReason = meta.failureReason;
96
+ }
97
+ return envelope;
98
+ }
99
+
100
+ /**
101
+ * Emit a lifecycle event to stderr (human-readable) and optionally persist
102
+ * a JSON artifact under the output audit directory.
103
+ *
104
+ * @param {'start'|'update'|'complete'|'abort'|'fail'} eventType
105
+ * @param {object} envelope - The lifecycle envelope.
106
+ * @param {{ silent?: boolean, outputDir?: string, persist?: boolean }} [opts]
107
+ */
108
+ export function emitLifecycleEvent(eventType, envelope, opts = {}) {
109
+ const event = {
110
+ event: eventType,
111
+ timestamp: new Date().toISOString(),
112
+ ...envelope,
113
+ };
114
+
115
+ if (!opts.silent) {
116
+ const routeTag = envelope.route ? `[${envelope.route}]` : "[unknown]";
117
+ const statusTag = envelope.status || "?";
118
+ console.error(
119
+ `[comet-lifecycle] ${routeTag} ${eventType} run=${envelope.runId} task=${envelope.taskThreadId} status=${statusTag}`
120
+ );
121
+ }
122
+
123
+ if (opts.persist !== false) {
124
+ const outDir = opts.outputDir || getDefaultAuditDir();
125
+ persistEvent(outDir, event);
126
+ }
127
+
128
+ // S070-T019: feed operational telemetry pipeline
129
+ emitTelemetryForLifecycle(eventType, envelope);
130
+
131
+ return event;
132
+ }
133
+
134
+ /**
135
+ * Validate that an envelope has all required fields.
136
+ * Returns { valid: true } or { valid: false, missing: string[] }.
137
+ */
138
+ export function validateEnvelope(envelope) {
139
+ const missing = REQUIRED_FIELDS.filter((f) => !envelope[f]);
140
+ return missing.length === 0 ? { valid: true, missing: [] } : { valid: false, missing };
141
+ }
142
+
143
+ /**
144
+ * Build environment variables map to propagate lifecycle context to child scripts.
145
+ * Child scripts can read these to participate in the same lifecycle scope.
146
+ */
147
+ export function lifecycleEnvVars(envelope) {
148
+ const vars = {
149
+ COMET_RUN_ID: envelope.runId,
150
+ COMET_RUN_ROUTE: envelope.route || "cli",
151
+ COMET_RUN_STATUS: envelope.status,
152
+ };
153
+ if (envelope.taskThreadId && envelope.taskThreadId !== "unknown") {
154
+ vars.COMET_TASK_GROUP = envelope.taskThreadId;
155
+ }
156
+ if (envelope.agentId) {
157
+ vars.COMET_AGENT_ID = envelope.agentId;
158
+ }
159
+ if (envelope.auditSessionId) {
160
+ vars.COMET_AUDIT_SESSION_ID = envelope.auditSessionId;
161
+ }
162
+ return vars;
163
+ }
164
+
165
+ /**
166
+ * Collect lifecycle context from environment variables.
167
+ * Inverse of lifecycleEnvVars; used by child scripts to discover parent lifecycle.
168
+ */
169
+ export function readLifecycleFromEnv() {
170
+ return {
171
+ runId: process.env.COMET_RUN_ID || null,
172
+ route: process.env.COMET_RUN_ROUTE || null,
173
+ status: process.env.COMET_RUN_STATUS || null,
174
+ taskThreadId: process.env.COMET_TASK_GROUP || null,
175
+ agentId: process.env.COMET_AGENT_ID || null,
176
+ auditSessionId: process.env.COMET_AUDIT_SESSION_ID || null,
177
+ };
178
+ }
179
+
180
+ // ── Internal helpers ───────────────────────────────────────────
181
+
182
+ /**
183
+ * Map lifecycle events to operational telemetry (S070-T019).
184
+ * Captures route usage on start, fallback triggers, and completion outcomes.
185
+ */
186
+ function emitTelemetryForLifecycle(eventType, envelope) {
187
+ try {
188
+ if (eventType === "start") {
189
+ recordTelemetryEvent({
190
+ type: "route",
191
+ route: envelope.route || "cli",
192
+ channel: `comet-${envelope.route || "cli"}`,
193
+ agentId: envelope.agentId,
194
+ taskThreadId: envelope.taskThreadId,
195
+ runId: envelope.runId,
196
+ });
197
+ if (envelope.fallbackUsed) {
198
+ recordTelemetryEvent({
199
+ type: "fallback",
200
+ fallbackUsed: true,
201
+ fallbackReason: `primary-${envelope.route === "cli" ? "mcp" : envelope.route}-unavailable`,
202
+ agentId: envelope.agentId,
203
+ taskThreadId: envelope.taskThreadId,
204
+ runId: envelope.runId,
205
+ });
206
+ }
207
+ }
208
+ if (eventType === "complete" || eventType === "abort" || eventType === "fail") {
209
+ const outcomeMap = { complete: "completed", abort: "aborted", fail: "failed" };
210
+ const durationMs =
211
+ envelope.startedAt && envelope.completedAt
212
+ ? new Date(envelope.completedAt).getTime() - new Date(envelope.startedAt).getTime()
213
+ : undefined;
214
+ recordTelemetryEvent({
215
+ type: "completion",
216
+ outcome: outcomeMap[eventType] || envelope.status,
217
+ runId: envelope.runId,
218
+ taskThreadId: envelope.taskThreadId,
219
+ agentId: envelope.agentId,
220
+ durationMs: durationMs > 0 ? durationMs : undefined,
221
+ });
222
+ }
223
+ } catch {
224
+ // telemetry is best-effort
225
+ }
226
+ }
227
+
228
+ function getDefaultAuditDir() {
229
+ const base =
230
+ process.env.COMET_OUTPUT_BASE ||
231
+ join(process.env.HOME || "/tmp", ".claude/comet-browser/output");
232
+ return join(base, "audit");
233
+ }
234
+
235
+ function persistEvent(dir, event) {
236
+ try {
237
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
238
+ const filename = `lifecycle-${event.event}-${event.runId}-${Date.now()}.json`;
239
+ writeFileSync(join(dir, filename), JSON.stringify(event, null, 2));
240
+ } catch (err) {
241
+ console.error(`[comet-lifecycle] Failed to persist event: ${err.message}`);
242
+ }
243
+ }
244
+
245
+ export default {
246
+ createLifecycleEnvelope,
247
+ transitionEnvelope,
248
+ emitLifecycleEvent,
249
+ validateEnvelope,
250
+ lifecycleEnvVars,
251
+ readLifecycleFromEnv,
252
+ };