@comty.io/core 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "name": "@comty.io/core",
3
- "version": "0.1.5",
4
- "description": "Comty visual comments and inspector",
3
+ "version": "0.1.7",
4
+ "description": "Comty visual comments, inspector and local agent tools",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
7
7
  "engines": {
8
8
  "node": ">=22.12"
9
9
  },
10
10
  "sideEffects": false,
11
+ "bin": {
12
+ "comty": "src/dev.mjs"
13
+ },
11
14
  "exports": {
12
15
  ".": {
13
16
  "types": "./src/browser.d.ts",
14
17
  "import": "./src/browser.js"
18
+ },
19
+ "./vite": {
20
+ "types": "./src/vite.d.ts",
21
+ "import": "./src/vite.mjs"
15
22
  }
16
23
  },
17
24
  "files": [
@@ -25,14 +32,18 @@
25
32
  "scripts": {
26
33
  "build": "node scripts/build.mjs",
27
34
  "check:build": "node scripts/build.mjs --check",
28
- "test": "node --test tests/measurements.test.mjs tests/anchors.test.mjs tests/package.test.mjs tests/packed.test.mjs",
35
+ "test": "node --experimental-websocket --test tests/measurements.test.mjs tests/anchors.test.mjs tests/package.test.mjs tests/packed.test.mjs tests/companion.test.mjs tests/codex.test.mjs tests/claude.test.mjs tests/cursor.test.mjs tests/acp.test.mjs",
29
36
  "test:capture": "node --test tests/capture.browser.test.mjs",
30
- "test:inspector": "node --test tests/inspector.browser.test.mjs",
37
+ "test:inspector": "node --test tests/inspector.browser.test.mjs tests/inspect-markup.browser.test.mjs tests/idle-network.browser.test.mjs",
38
+ "test:placement": "node --test tests/placement.browser.test.mjs",
39
+ "test:anchors": "node --test tests/anchors.test.mjs tests/anchor-reload.browser.test.mjs",
40
+ "test:vite": "node --test tests/vite.test.mjs",
31
41
  "prepack": "npm run build"
32
42
  },
33
43
  "devDependencies": {
34
- "esbuild": "0.25.12",
44
+ "esbuild": "0.28.2",
35
45
  "html-to-image": "1.11.13",
36
- "playwright": "1.55.1"
46
+ "playwright": "1.55.1",
47
+ "vite": "8.1.5"
37
48
  }
38
49
  }
package/src/acp.mjs ADDED
@@ -0,0 +1,68 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { openAgentProcess } from './agent-process.mjs';
3
+ import { parseReceipt, receiptSchema, taskPrompt } from './codex.mjs';
4
+
5
+ const agents = {
6
+ gemini: { executable: 'gemini', command: ['--acp'], label: 'Gemini' },
7
+ grok: { executable: 'grok', command: ['--no-auto-update', 'agent', 'stdio'], label: 'Grok' },
8
+ };
9
+
10
+ export async function runAcp({ provider, cwd, task, signal, approve, progress, model, executable, args = [] }) {
11
+ const agent = agents[provider];
12
+ if (!agent) throw new Error(`Unsupported ACP agent: ${provider}`);
13
+ cwd = realpathSync(cwd);
14
+ let output = '';
15
+ let resolveReceipt;
16
+ const receiptReady = new Promise(resolve => { resolveReceipt = resolve; });
17
+ const rpc = openAgentProcess({
18
+ executable: executable || agent.executable, args: [...args, ...(model ? ['--model', model] : []), ...agent.command], cwd, signal, label: agent.label,
19
+ async onRequest(method, params) {
20
+ if (method === 'session/request_permission') {
21
+ const allow = await approve({ type: method, details: params.toolCall });
22
+ const option = params.options?.find(item => item.kind === (allow ? 'allow_once' : 'reject_once'));
23
+ return { outcome: option ? { outcome: 'selected', optionId: option.optionId } : { outcome: 'cancelled' } };
24
+ }
25
+ throw new Error(`${agent.label} needs an unsupported interaction: ${method}`);
26
+ },
27
+ onNotification(method, params) {
28
+ if (method !== 'session/update' || params?.update?.sessionUpdate !== 'agent_message_chunk') return;
29
+ if (params.update.content?.type === 'text') {
30
+ output += params.update.content.text;
31
+ if (output.length > 1024 * 1024) throw new Error(`${agent.label} response exceeded 1 MiB`);
32
+ progress(output.slice(-4000));
33
+ if (output.trimEnd().endsWith('</comty_result>')) resolveReceipt();
34
+ }
35
+ },
36
+ });
37
+ try {
38
+ const init = await rpc.request('initialize', {
39
+ protocolVersion: 1, clientInfo: { name: 'comty', version: '0.1.0' },
40
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
41
+ });
42
+ const authMethods = new Set(init?.authMethods?.map(method => method.id) || []);
43
+ const methodId = provider === 'gemini' ? (process.env.GEMINI_API_KEY && authMethods.has('gemini-api-key') ? 'gemini-api-key' : 'oauth-personal')
44
+ : (process.env.XAI_API_KEY && authMethods.has('xai.api_key') ? 'xai.api_key' : 'cached_token');
45
+ if (provider === 'grok' && !authMethods.has(methodId)) throw new Error('Run grok login first, or set XAI_API_KEY');
46
+ if (provider === 'gemini' && !authMethods.has(methodId)) throw new Error('Sign in to Gemini CLI first, or set GEMINI_API_KEY');
47
+ await rpc.request('authenticate', { methodId, ...(provider === 'grok' ? { _meta: { headless: true } } : {}) });
48
+ const session = await rpc.request('session/new', { cwd, mcpServers: [] });
49
+ if (!session?.sessionId) throw new Error(`${agent.label} did not create a session`);
50
+ const result = await rpc.request('session/prompt', {
51
+ sessionId: session.sessionId,
52
+ prompt: [{ type: 'text', text: `${taskPrompt(task)}\n\nEnd your final response with <comty_result>JSON</comty_result>. The JSON must match this schema: ${JSON.stringify(receiptSchema)}. Write nothing after the closing tag.` }],
53
+ }, 0);
54
+ if (result?.stopReason !== 'end_turn') throw new Error(`${agent.label} stopped: ${result?.stopReason || 'unknown reason'}`);
55
+ if (!output.trimEnd().endsWith('</comty_result>')) {
56
+ let timeout;
57
+ try {
58
+ await Promise.race([receiptReady, new Promise((_, reject) => {
59
+ timeout = setTimeout(() => reject(new Error(`${agent.label} did not return a completion receipt`)), 2000);
60
+ })]);
61
+ } finally { clearTimeout(timeout); }
62
+ }
63
+ const text = output.trim();
64
+ const start = text.lastIndexOf('<comty_result>');
65
+ if (start < 0 || !text.endsWith('</comty_result>')) throw new Error(`${agent.label} did not return a completion receipt`);
66
+ return parseReceipt(text.slice(start + '<comty_result>'.length, -'</comty_result>'.length));
67
+ } finally { await rpc.close(); }
68
+ }
@@ -0,0 +1,89 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ // Codex app-server and ACP agents use newline-delimited JSON-RPC on stdio.
4
+ export function openAgentProcess({ executable, args, cwd, signal, label, onRequest, onNotification }) {
5
+ signal.throwIfAborted();
6
+ const child = spawn(executable, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
7
+ const pending = new Map();
8
+ let nextId = 0, buffer = '', stderr = '', closed = false, failureError, closing, rejectFailure;
9
+ const failure = new Promise((_, reject) => { rejectFailure = reject; });
10
+ failure.catch(() => {});
11
+ const exited = new Promise(resolve => child.once('close', () => { closed = true; resolve(); }));
12
+ function fail(error) {
13
+ failureError ||= error;
14
+ rejectFailure(failureError);
15
+ for (const waiter of pending.values()) { clearTimeout(waiter.timeout); waiter.reject(failureError); }
16
+ pending.clear();
17
+ }
18
+ function send(value) {
19
+ if (failureError) throw failureError;
20
+ if (closed || child.stdin.destroyed) throw new Error(`${label} connection closed`);
21
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...value })}\n`);
22
+ }
23
+ async function receive(message) {
24
+ if (message.method && message.id !== undefined) {
25
+ try { send({ id: message.id, result: await onRequest(message.method, message.params) }); }
26
+ catch (error) {
27
+ if (!failureError) send({ id: message.id, error: { code: -32603, message: error.message } });
28
+ throw error;
29
+ }
30
+ } else if (message.id !== undefined) {
31
+ const waiter = pending.get(message.id);
32
+ if (!waiter) return;
33
+ clearTimeout(waiter.timeout); pending.delete(message.id);
34
+ if (message.error) waiter.reject(new Error(message.error.message || `${label} request failed`));
35
+ else waiter.resolve(message.result);
36
+ } else if (message.method) await onNotification(message.method, message.params);
37
+ }
38
+ child.stdout.setEncoding('utf8');
39
+ child.stdout.on('data', chunk => {
40
+ buffer += chunk;
41
+ if (buffer.length > 8 * 1024 * 1024) return fail(new Error(`${label} output exceeded the message limit`));
42
+ let index;
43
+ while ((index = buffer.indexOf('\n')) !== -1) {
44
+ const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
45
+ if (!line.trim()) continue;
46
+ try { void receive(JSON.parse(line)).catch(fail); } catch (error) { fail(error); }
47
+ }
48
+ });
49
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-4000); });
50
+ child.stdin.on('error', fail);
51
+ child.on('error', error => fail(new Error(`Cannot start ${label}: ${error.message}. Install and sign in to its CLI.`)));
52
+ child.on('close', code => fail(new Error(`${label} exited (${code}): ${stderr || 'connection closed'}`)));
53
+ const abort = () => fail(signal.reason || new Error('Task cancelled'));
54
+ signal.addEventListener('abort', abort, { once: true });
55
+ return {
56
+ failure,
57
+ notify: (method, params) => send({ method, params }),
58
+ request(method, params, timeoutMs = 30_000) {
59
+ return new Promise((resolve, reject) => {
60
+ signal.throwIfAborted();
61
+ if (failureError) throw failureError;
62
+ const id = ++nextId;
63
+ const timeout = timeoutMs ? setTimeout(() => {
64
+ pending.delete(id); reject(new Error(`${label} timed out: ${method}`));
65
+ }, timeoutMs) : undefined;
66
+ pending.set(id, { resolve, reject, timeout });
67
+ try { send({ id, method, params }); } catch (error) {
68
+ clearTimeout(timeout); pending.delete(id); reject(error);
69
+ }
70
+ });
71
+ },
72
+ close() {
73
+ if (closing) return closing;
74
+ signal.removeEventListener('abort', abort);
75
+ fail(new Error(`${label} session closed`));
76
+ const kill = signalName => {
77
+ if (closed) return;
78
+ try {
79
+ if (process.platform === 'win32') child.kill(signalName);
80
+ else if (child.pid) process.kill(-child.pid, signalName);
81
+ } catch (error) { if (error.code !== 'ESRCH') throw error; }
82
+ };
83
+ kill('SIGTERM');
84
+ const timer = setTimeout(() => kill('SIGKILL'), 2000);
85
+ closing = exited.finally(() => clearTimeout(timer));
86
+ return closing;
87
+ },
88
+ };
89
+ }
package/src/anchors.js CHANGED
@@ -11,6 +11,14 @@ export function createAnchorMatcher({
11
11
  return haystack.toLowerCase().includes(needle.toLowerCase());
12
12
  }
13
13
 
14
+ function similarText(a, b) {
15
+ if (!a || !b) return false;
16
+ const words = value => new Set(value.toLowerCase().split(/\s+/).filter(Boolean));
17
+ const left = words(a), right = words(b);
18
+ const common = [...left].filter(word => right.has(word)).length;
19
+ return common >= 2 && common / Math.max(left.size, right.size) >= 0.7;
20
+ }
21
+
14
22
  function scoreFingerprint(el, fingerprint) {
15
23
  if (!el || !fingerprint) return 0;
16
24
  if (
@@ -80,6 +88,8 @@ export function createAnchorMatcher({
80
88
  score += 15;
81
89
  }
82
90
 
91
+ if (text !== fingerprint.text && similarText(text, fingerprint.text)) score += 24;
92
+
83
93
  if (typeof fingerprint.siblingIndex === "number" && el.parentElement) {
84
94
  const siblings = [...el.parentElement.children].filter(
85
95
  (child) => child.tagName === el.tagName,
@@ -157,6 +167,8 @@ export function createAnchorMatcher({
157
167
  if (ancestorScore > 0) {
158
168
  score += Math.min(ancestorScore, 40) * (index === 0 ? 0.5 : 0.2);
159
169
  }
170
+ // Distinguish repeated cards by their section, even when dimensions match.
171
+ if (index < 3 && ancestors[index].text && getElementTextSignature(parent) === ancestors[index].text) score += 18;
160
172
  parent = parent.parentElement;
161
173
  }
162
174
 
@@ -171,46 +183,75 @@ export function createAnchorMatcher({
171
183
  return score;
172
184
  }
173
185
 
174
- function matchesAnchorIdentity(el, fingerprint) {
186
+ function matchesAnchorIdentity(el, fingerprint, ignoreDescendantText = false, allowTextChanges = true) {
175
187
  if (!el || !fingerprint || el.tagName.toLowerCase() !== fingerprint.tag) return false;
176
188
  const attributes = { ariaLabel: "aria-label", name: "name", href: "href", src: "src", alt: "alt", title: "title", role: "role" };
177
189
  for (const [key, attribute] of Object.entries(attributes)) {
178
190
  if (fingerprint[key] && getAttributeSignature(el, attribute) !== fingerprint[key]) return false;
179
191
  }
180
- if (fingerprint.ownText && getElementOwnTextSignature(el) !== fingerprint.ownText) return false;
192
+ if (fingerprint.id && el.id !== fingerprint.id) return false;
193
+ if (fingerprint.testId && !["data-testid", "data-test", "data-cy"].some(name => getAttributeSignature(el, name) === fingerprint.testId)) return false;
194
+ const stableIdentity = (fingerprint.id && el.id === fingerprint.id) ||
195
+ (fingerprint.testId && ["data-testid", "data-test", "data-cy"].some(name => getAttributeSignature(el, name) === fingerprint.testId));
196
+ if (fingerprint.ownText && getElementOwnTextSignature(el) !== fingerprint.ownText && !(allowTextChanges && stableIdentity)) return false;
181
197
  // Container text is useful for matching remounted drawers and dialogs.
182
- if (!fingerprint.ownText && fingerprint.text && getElementTextSignature(el) !== fingerprint.text) return false;
198
+ if (!ignoreDescendantText && !fingerprint.ownText && fingerprint.text && getElementTextSignature(el) !== fingerprint.text && !(allowTextChanges && stableIdentity)) {
199
+ if (!allowTextChanges || !el.children.length || !similarText(getElementTextSignature(el), fingerprint.text)) return false;
200
+ }
183
201
  return true;
184
202
  }
185
203
 
186
204
  function matchesAnchorContext(el, context) {
187
205
  if (!context) return true;
188
- if (!matchesAnchorIdentity(el, context.target)) return false;
206
+ // A unique page landmark keeps its identity when a child is edited.
207
+ const pageContainer = !context.layer && (context.target?.tag === "main" || context.target?.role === "main") &&
208
+ [...document.querySelectorAll('main, [role="main"]')].filter(node => !isComtyNode(node)).length === 1;
209
+ if (!matchesAnchorIdentity(el, context.target, pageContainer)) return false;
189
210
  const parentFingerprint = context.ancestors?.[0];
190
211
  if (parentFingerprint?.text && parentFingerprint.text !== context.target.text &&
191
- !matchesAnchorIdentity(el.parentElement, parentFingerprint)) return false;
212
+ !matchesAnchorIdentity(el.parentElement, parentFingerprint, pageContainer)) return false;
192
213
  const layer = getPresentationLayer(el);
193
- if (context.layer && (!layer || !matchesAnchorIdentity(layer, context.layer))) return false;
214
+ if (context.layer && (!layer || !matchesAnchorIdentity(layer, context.layer, false, false))) return false;
194
215
  if (context.version >= 2 && !context.layer && layer) return false;
195
216
  return scoreAnchorCandidate(el, context) >= 50;
196
217
  }
197
218
 
198
- function resolveByAnchorContext(context) {
199
- if (!context?.target) return null;
219
+ function resolveAnchor(context, exact = null) {
220
+ if (!context?.target) return { element: exact, status: exact ? "matched" : "missing" };
200
221
  let candidates;
201
222
  try {
202
- candidates = [...document.body.querySelectorAll(context.target.tag || "*")];
223
+ candidates = [...document.body.querySelectorAll(context.target.tag || "*")].filter(el => !isComtyNode(el));
203
224
  } catch {
204
- return null;
225
+ return { element: null, status: "missing" };
205
226
  }
206
227
  const matches = candidates
207
- .filter(el => !isComtyNode(el) && matchesAnchorContext(el, context))
208
- .map(el => ({ el, score: scoreAnchorCandidate(el, context) }))
228
+ .filter(el => matchesAnchorContext(el, context))
229
+ .map(el => ({ el, score: scoreAnchorCandidate(el, context) + (context.version === 1 && el === exact ? 16 : 0) }))
209
230
  .sort((a, b) => b.score - a.score);
210
- if (!matches.length || matches[0].score < 58) return null;
211
- // ponytail: ambiguous remounts stay hidden; explicit reattachment can handle identical twins later.
212
- if (matches[1] && matches[0].score - matches[1].score < 12) return null;
213
- return matches[0].el;
231
+ if (context.candidateCount > 1) {
232
+ const peers = candidates.filter(el => matchesAnchorIdentity(el, context.target));
233
+ if (peers.length !== context.candidateCount) {
234
+ // A removed identical sibling must not move its comment to the survivor.
235
+ const distinctive = matches[0] && (context.target.id || context.target.testId);
236
+ if (!distinctive) return { element: null, status: "uncertain" };
237
+ }
238
+ }
239
+ if (matches[0]?.score >= 58 && (!matches[1] || matches[0].score - matches[1].score >= 12)) {
240
+ return { element: matches[0].el, status: "matched" };
241
+ }
242
+ // A closed/unmounted overlay is not evidence that its contents were deleted.
243
+ if (context.layer && !candidates.some(el => {
244
+ const layer = getPresentationLayer(el);
245
+ return layer && matchesAnchorIdentity(layer, context.layer, false, false);
246
+ })) return { element: null, status: "hidden" };
247
+ const plausible = matches.length || candidates.some(el =>
248
+ (context.target.id && el.id === context.target.id) ||
249
+ (context.target.text && (getElementTextSignature(el) === context.target.text || similarText(getElementTextSignature(el), context.target.text))));
250
+ return { element: null, status: plausible ? "uncertain" : "missing" };
251
+ }
252
+
253
+ function resolveByAnchorContext(context) {
254
+ return resolveAnchor(context).element;
214
255
  }
215
256
 
216
257
  function getViewportBounds() {
@@ -382,5 +423,5 @@ export function createAnchorMatcher({
382
423
  return { clientX, clientY };
383
424
  }
384
425
 
385
- return { matchesAnchorContext, resolveByAnchorContext, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint };
426
+ return { matchesAnchorContext, resolveAnchor, resolveByAnchorContext, isElementTreeHidden, getTopActiveModal, getVisibleAnchorPoint };
386
427
  }
package/src/browser.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export interface ComtyOptions {
2
2
  /** Project key from your Comty dashboard. */
3
- projectKey: string;
3
+ projectKey?: string;
4
4
  /** Absolute URL of your Comty API. Defaults to http://localhost:4000. */
5
5
  apiUrl?: string;
6
+ /** Set automatically by comty dev; not needed in application code. */
7
+ agent?: { url: string; token: string };
6
8
  }
7
9
 
8
10
  /**