@comment-io/cli 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Every
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # Comment Docs
2
+
3
+ Collaborative markdown editor with provenance tracking, comments, suggestions, and an agent API. Powers [Comment.io](https://comment.io).
4
+
5
+ ## Stack
6
+
7
+ - **Backend** (`cf/`) — Cloudflare Workers + Durable Objects + Yjs
8
+ - **Frontend** (`src/`) — React 19, Zustand, Milkdown, Tailwind CSS v4, Vite
9
+
10
+ ## Local Development
11
+
12
+ ```bash
13
+ make dev # CF Worker (:8787) + Vite editor (:3100) — background
14
+ make dev-stop # Stop dev servers
15
+ make logs # Tail structured logs (jq)
16
+ ```
17
+
18
+ ## Tests
19
+
20
+ ```bash
21
+ cd cf && npx vitest run # Backend test suite
22
+ npx vite build # Verify frontend builds
23
+ ```
24
+
25
+ ## Agent API
26
+
27
+ The canonical reference for the agent-facing REST API is served at `/llms.txt` (and `/docs/api` for the interactive spec). Starting points:
28
+
29
+ - `POST /docs` — create a document
30
+ - `GET /docs/:id` — read (markdown + marks)
31
+ - `PATCH /docs/:id` — edit via `{ old_string, new_string }` patches
32
+ - `POST /docs/:id/comments` — comment / suggest / reply
33
+
34
+ ## CommentFS Local Sync
35
+
36
+ CommentFS can project selected Comment.io docs into read-only local markdown
37
+ files under `~/Comment Docs`.
38
+
39
+ Install the CLI:
40
+
41
+ ```bash
42
+ npm install -g @comment-io/cli
43
+ ```
44
+
45
+ ```bash
46
+ comment sync login --api-key <usk_...>
47
+ comment sync
48
+ comment sync watch
49
+ ```
50
+
51
+ Enable **Sync locally** on a document first. Local files are not an edit path;
52
+ edit through the UI or API. See `docs/COMMENTFS-SYNC-USAGE.md`.
53
+
54
+ ## Local Notification Daemon
55
+
56
+ Registered agents can receive @mention notifications through the local daemon:
57
+
58
+ ```bash
59
+ comment daemon install
60
+ comment daemon health
61
+ comment notifications wait --profile <handle> --timeout 30m
62
+ ```
63
+
64
+ The notification wait command leases work locally. Ack the delivered claim after
65
+ handling it, or release it so another worker can retry:
66
+
67
+ ```bash
68
+ comment notifications ack <claim-id>
69
+ comment notifications release <claim-id>
70
+ ```
71
+
72
+ ## Docs
73
+
74
+ - `CLAUDE.md` — repo-level agent/developer instructions
75
+ - `docs/LOGGING.md` — structured logging guide
76
+ - `docs/ARCHITECTURE.md` — architecture notes
package/bin/comment.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const binDir = dirname(fileURLToPath(import.meta.url));
7
+ const packageRoot = resolve(binDir, '..');
8
+ const tsxCli = resolve(packageRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
9
+ const syncCli = resolve(packageRoot, 'scripts', 'commentfs-sync.ts');
10
+
11
+ const child = spawnSync(process.execPath, [tsxCli, syncCli, ...process.argv.slice(2)], {
12
+ stdio: 'inherit',
13
+ });
14
+
15
+ if (child.error) {
16
+ console.error(child.error.message);
17
+ process.exit(1);
18
+ }
19
+
20
+ if (child.signal) {
21
+ process.kill(process.pid, child.signal);
22
+ } else {
23
+ process.exit(child.status ?? 0);
24
+ }
@@ -0,0 +1,88 @@
1
+ # CommentFS Read-Only Local Sync
2
+
3
+ CommentFS projects selected Comment.io documents into local markdown files. The
4
+ online Comment.io document is canonical. Local markdown files are read-only
5
+ snapshots for search, context, indexing, and agent inspection.
6
+
7
+ Do not edit synced markdown files directly. Humans should edit through the
8
+ Comment.io UI. Agents should edit through the Comment.io API.
9
+
10
+ ## Setup
11
+
12
+ The CommentFS UI is currently gated behind `VITE_ENABLE_COMMENTFS_UI=true` so it
13
+ can be enabled on staging without exposing the flow in production.
14
+
15
+ 1. Open Comment.io settings and generate a CommentFS key.
16
+ 2. Configure the local CLI:
17
+
18
+ ```bash
19
+ comment sync login --api-key <usk_...>
20
+ ```
21
+
22
+ For staging or another deployment, include the base URL:
23
+
24
+ ```bash
25
+ comment sync login --api-key <usk_...> --base-url https://staging.example.com
26
+ ```
27
+
28
+ 3. Open a document's access panel and enable **Sync locally**.
29
+ 4. Run one sync:
30
+
31
+ ```bash
32
+ comment sync
33
+ ```
34
+
35
+ 5. Keep projections updated:
36
+
37
+ ```bash
38
+ comment sync watch
39
+ ```
40
+
41
+ By default files are written under `~/Comment Docs`.
42
+
43
+ ## Useful Commands
44
+
45
+ ```bash
46
+ comment sync status
47
+ comment sync repair
48
+ comment sync explain <path>
49
+ comment sync recover <path>
50
+ comment sync watch --interval 10s --full-interval 5m
51
+ ```
52
+
53
+ `comment sync repair` restores read-only permissions on existing projections.
54
+ `comment sync explain` points a markdown or sidecar path back to its source
55
+ document and API docs. `comment sync recover` explains a preserved local edit
56
+ artifact.
57
+
58
+ ## Sidecars
59
+
60
+ Each sync root has a `.comment/` folder. Important files include:
61
+
62
+ - `.comment/manifest.json`: local projection manifest.
63
+ - `.comment/docs/<slug>/status.json`: sync health, source URL, revision, sidecar
64
+ paths, and recovery metadata.
65
+ - `.comment/docs/<slug>/edit.md`: short edit instructions and API doc links.
66
+ - `.comment/docs/<slug>/authorship.json`: authorship/provenance metadata.
67
+ - `.comment/docs/<slug>/comments.json`: comment and suggestion metadata.
68
+ - `.comment/docs/<slug>/participants.json`: participant metadata.
69
+ - `.comment/recovery/*.local.md`: preserved local text from unsupported local
70
+ edits.
71
+
72
+ ## Local Edits
73
+
74
+ If a local tool changes a synced markdown file, the next sync preserves that
75
+ local text under `.comment/recovery/` and restores the canonical Comment.io
76
+ version. It does not upload local markdown edits.
77
+
78
+ To apply an intended change, open the document in Comment.io or use the API docs
79
+ linked from the sidecar `api_docs_url`.
80
+
81
+ ## Auth Notes
82
+
83
+ `COMMENT_IO_USER_API_KEY` and `usk_` keys are only for read-only projection sync.
84
+ They can poll selected docs and fetch projections. They cannot write documents.
85
+
86
+ Agent edits need an edit-capable Comment.io credential such as
87
+ `COMMENT_IO_AGENT_SECRET`, a registered agent credential, or an edit-capable
88
+ per-document token.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@comment-io/cli",
3
+ "version": "0.1.0",
4
+ "description": "Comment.io CLI and local notification daemon",
5
+ "private": false,
6
+ "type": "module",
7
+ "homepage": "https://comment.io",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+ssh://git@github.com/comment-io/comment.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/comment-io/comment/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "bin": {
19
+ "comment": "bin/comment.js"
20
+ },
21
+ "files": [
22
+ "bin/",
23
+ "scripts/commentfs-sync.ts",
24
+ "scripts/commentd.ts",
25
+ "shared/commentfs-sync.ts",
26
+ "shared/comment-notifications.ts",
27
+ "docs/COMMENTFS-SYNC-USAGE.md",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "prepack": "node ../../scripts/prepare-cli-package.mjs"
33
+ },
34
+ "dependencies": {
35
+ "tsx": "^4.21.0"
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ }
40
+ }
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { rm, readFile } from 'node:fs/promises';
5
+ import { hostname, homedir, platform } from 'node:os';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import net from 'node:net';
9
+ import {
10
+ DEFAULT_COMMENTFS_BASE_URL,
11
+ DEFAULT_SYNC_ROOT,
12
+ readCommentFsConfig,
13
+ syncConfiguredCommentDocsSettled,
14
+ syncRemoteSelectedCommentDocsSettled,
15
+ } from '../shared/commentfs-sync.js';
16
+ import {
17
+ appendLog,
18
+ buildQueueEnvelope,
19
+ daemonLogPath,
20
+ daemonPidPath,
21
+ daemonRequest,
22
+ daemonSocketPath,
23
+ ensurePrivateDir,
24
+ findClaimFile,
25
+ isProcessAlive,
26
+ loadAgentProfiles,
27
+ readPid,
28
+ removeClaimFile,
29
+ republishClaimFile,
30
+ sweepExpiredLocalClaims,
31
+ writePid,
32
+ writeQueueEnvelope,
33
+ type CommentAgentProfile,
34
+ type DaemonRequest,
35
+ type DaemonResponse,
36
+ type NotificationLeaseEnvelope,
37
+ type NotificationPayload,
38
+ } from '../shared/comment-notifications.js';
39
+
40
+ interface DaemonOptions {
41
+ rootDir?: string;
42
+ homeDir?: string;
43
+ intervalMs?: number;
44
+ fullIntervalMs?: number;
45
+ maxBackoffMs?: number;
46
+ }
47
+
48
+ interface NotificationProfileHealth {
49
+ handle: string;
50
+ connected: boolean;
51
+ lastConnectedAt: string | null;
52
+ lastMessageAt: string | null;
53
+ lastLeaseAt: string | null;
54
+ lastError: string | null;
55
+ reconnects: number;
56
+ }
57
+
58
+ interface DaemonHealth {
59
+ pid: number;
60
+ started_at: string;
61
+ socket_path: string;
62
+ sync: {
63
+ enabled: boolean;
64
+ last_attempt_at: string | null;
65
+ last_success_at: string | null;
66
+ last_error_at: string | null;
67
+ last_error: string | null;
68
+ consecutive_failures: number;
69
+ };
70
+ notifications: Record<string, NotificationProfileHealth>;
71
+ }
72
+
73
+ const defaultOptions: Required<DaemonOptions> = {
74
+ rootDir: DEFAULT_SYNC_ROOT,
75
+ homeDir: homedir(),
76
+ intervalMs: 10_000,
77
+ fullIntervalMs: 5 * 60_000,
78
+ maxBackoffMs: 60_000,
79
+ };
80
+
81
+ export async function runCommentDaemon(options: DaemonOptions = {}): Promise<void> {
82
+ const opts = { ...defaultOptions, ...options };
83
+ const socketPath = daemonSocketPath(opts.homeDir);
84
+ const pidPath = daemonPidPath(opts.homeDir);
85
+ const logPath = daemonLogPath(new Date(), opts.homeDir);
86
+ const abort = new AbortController();
87
+ const startedAt = new Date().toISOString();
88
+ const profileHealth = new Map<string, NotificationProfileHealth>();
89
+ const health: DaemonHealth = {
90
+ pid: process.pid,
91
+ started_at: startedAt,
92
+ socket_path: socketPath,
93
+ sync: {
94
+ enabled: false,
95
+ last_attempt_at: null,
96
+ last_success_at: null,
97
+ last_error_at: null,
98
+ last_error: null,
99
+ consecutive_failures: 0,
100
+ },
101
+ notifications: {},
102
+ };
103
+
104
+ async function log(level: 'info' | 'warn' | 'error', msg: string, data: Record<string, unknown> = {}): Promise<void> {
105
+ await appendLog(logPath, JSON.stringify({ ts: new Date().toISOString(), level, component: 'commentd', msg, data }));
106
+ }
107
+
108
+ await ensurePrivateDir(dirname(pidPath));
109
+ const existingPid = await readPid(pidPath);
110
+ if (existingPid && await isProcessAlive(existingPid)) {
111
+ throw new Error(`comment daemon already running with pid ${existingPid}`);
112
+ }
113
+ await writePid(pidPath);
114
+
115
+ if (platform() !== 'win32' && existsSync(socketPath)) {
116
+ const resp = await daemonRequest({ op: 'health' }, opts.homeDir, 500);
117
+ if (resp.ok) throw new Error(`comment daemon already listening at ${socketPath}`);
118
+ await rm(socketPath, { force: true });
119
+ }
120
+
121
+ const server = net.createServer((socket) => {
122
+ let buffer = '';
123
+ socket.on('data', (chunk) => {
124
+ buffer += chunk.toString('utf-8');
125
+ const idx = buffer.indexOf('\n');
126
+ if (idx < 0) return;
127
+ const raw = buffer.slice(0, idx);
128
+ buffer = buffer.slice(idx + 1);
129
+ handleSocketRequest(raw)
130
+ .then((response) => socket.end(`${JSON.stringify(response)}\n`))
131
+ .catch((err) => socket.end(`${JSON.stringify({ ok: false, error: String(err), code: 'INTERNAL' })}\n`));
132
+ });
133
+ });
134
+
135
+ async function handleSocketRequest(raw: string): Promise<DaemonResponse> {
136
+ let request: DaemonRequest;
137
+ try {
138
+ request = JSON.parse(raw) as DaemonRequest;
139
+ } catch {
140
+ return { ok: false, error: 'Invalid JSON', code: 'BAD_REQUEST' };
141
+ }
142
+
143
+ if (request.op === 'health') {
144
+ health.notifications = Object.fromEntries(profileHealth);
145
+ return { ok: true, op: 'health', health };
146
+ }
147
+ if (request.op === 'stop') {
148
+ abort.abort();
149
+ return { ok: true, op: 'stop' };
150
+ }
151
+ if (request.op === 'reload-profiles') {
152
+ await log('info', 'daemon.reload_profiles_requested');
153
+ return { ok: true, op: 'reload-profiles' };
154
+ }
155
+ if (request.op === 'ack' || request.op === 'release') {
156
+ if (!request.claim_id) return { ok: false, error: 'Missing claim_id', code: 'VALIDATION_ERROR' };
157
+ return proxyClaim(request.op, request.claim_id);
158
+ }
159
+ return { ok: false, error: `Unknown op: ${(request as { op?: string }).op}`, code: 'UNKNOWN_OP' };
160
+ }
161
+
162
+ async function proxyClaim(op: 'ack' | 'release', claimId: string): Promise<DaemonResponse> {
163
+ const found = await findClaimFile(claimId, opts.homeDir);
164
+ if (!found) return { ok: false, error: `Unknown local claim ${claimId}`, code: 'CLAIM_NOT_FOUND' };
165
+ const envelope = JSON.parse(await readFile(found.path, 'utf-8')) as NotificationLeaseEnvelope;
166
+ const profiles = await loadAgentProfiles({ homeDir: opts.homeDir, defaultBaseUrl: envelope.base_url });
167
+ const profile = profiles.find((item) => item.handle === envelope.profile);
168
+ if (!profile) return { ok: false, error: `Profile not configured: ${envelope.profile}`, code: 'PROFILE_NOT_FOUND' };
169
+
170
+ const url = `${profile.baseUrl}/agents/me/notifications/claim/${encodeURIComponent(claimId)}/${op}`;
171
+ const resp = await fetch(url, {
172
+ method: 'POST',
173
+ headers: { Authorization: `Bearer ${profile.agentSecret}` },
174
+ });
175
+ const body = await resp.text();
176
+ if (!resp.ok) {
177
+ await log('warn', `notification.${op}_failed`, { claim_id: claimId, status: resp.status, body });
178
+ return { ok: false, error: body || `${op} failed`, code: `SERVER_${resp.status}` };
179
+ }
180
+
181
+ if (op === 'ack') await removeClaimFile(claimId, opts.homeDir);
182
+ else await republishClaimFile(claimId, opts.homeDir);
183
+ await log('info', `notification.${op}`, { claim_id: claimId, profile: envelope.profile });
184
+ return { ok: true, op, claim_id: claimId };
185
+ }
186
+
187
+ await new Promise<void>((resolvePromise, reject) => {
188
+ server.once('error', reject);
189
+ server.listen(socketPath, () => resolvePromise());
190
+ });
191
+ if (platform() !== 'win32') {
192
+ await import('node:fs/promises').then(({ chmod }) => chmod(socketPath, 0o600)).catch(() => {});
193
+ }
194
+
195
+ await log('info', 'daemon.started', { pid: process.pid, socket_path: socketPath });
196
+
197
+ const loops = [
198
+ runSyncLoop(opts, health, abort.signal, log),
199
+ runNotificationLoops(opts, profileHealth, abort.signal, log),
200
+ runLocalSweepLoop(opts.homeDir, abort.signal, log),
201
+ ];
202
+
203
+ const stop = async () => {
204
+ abort.abort();
205
+ server.close();
206
+ if (platform() !== 'win32') await rm(socketPath, { force: true });
207
+ await rm(pidPath, { force: true });
208
+ await log('info', 'daemon.stopped');
209
+ };
210
+
211
+ process.once('SIGTERM', () => { void stop(); });
212
+ process.once('SIGINT', () => { void stop(); });
213
+
214
+ await Promise.race([
215
+ Promise.allSettled(loops),
216
+ new Promise<void>((resolvePromise) => abort.signal.addEventListener('abort', () => resolvePromise(), { once: true })),
217
+ ]);
218
+ await stop();
219
+ }
220
+
221
+ async function runSyncLoop(
222
+ opts: Required<DaemonOptions>,
223
+ health: DaemonHealth,
224
+ signal: AbortSignal,
225
+ log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
226
+ ): Promise<void> {
227
+ let lastFullSyncAt = 0;
228
+ while (!signal.aborted) {
229
+ const attemptedAt = new Date().toISOString();
230
+ health.sync.last_attempt_at = attemptedAt;
231
+ try {
232
+ const configured = await readCommentFsConfig(opts.homeDir).catch(() => null);
233
+ const userApiKey = process.env.COMMENT_IO_USER_API_KEY ?? configured?.userApiKey;
234
+ const baseUrl = (configured?.baseUrl ?? DEFAULT_COMMENTFS_BASE_URL).replace(/\/$/, '');
235
+ health.sync.enabled = Boolean(userApiKey);
236
+ const full = Boolean(userApiKey && (lastFullSyncAt === 0 || Date.now() - lastFullSyncAt >= opts.fullIntervalMs));
237
+ const results = userApiKey
238
+ ? await syncRemoteSelectedCommentDocsSettled({ rootDir: resolve(opts.rootDir), baseUrl, userApiKey, full, homeDir: opts.homeDir })
239
+ : await syncConfiguredCommentDocsSettled({ rootDir: resolve(opts.rootDir), homeDir: opts.homeDir });
240
+ if (full) lastFullSyncAt = Date.now();
241
+ const failures = results.filter((result) => !result.ok).length;
242
+ if (failures > 0) throw new Error(`${failures} CommentFS sync item(s) failed`);
243
+ health.sync.last_success_at = new Date().toISOString();
244
+ health.sync.last_error = null;
245
+ health.sync.last_error_at = null;
246
+ health.sync.consecutive_failures = 0;
247
+ await log('info', 'sync.poll_ok', { count: results.length, full });
248
+ await delay(opts.intervalMs, signal);
249
+ } catch (err) {
250
+ health.sync.consecutive_failures += 1;
251
+ health.sync.last_error_at = new Date().toISOString();
252
+ health.sync.last_error = err instanceof Error ? err.message : String(err);
253
+ const backoff = Math.min(opts.maxBackoffMs, Math.max(opts.intervalMs, opts.intervalMs * 2 ** Math.min(health.sync.consecutive_failures - 1, 6)));
254
+ await log('warn', 'sync.poll_failed', { error: health.sync.last_error, retry_ms: backoff });
255
+ await delay(backoff, signal);
256
+ }
257
+ }
258
+ }
259
+
260
+ async function runNotificationLoops(
261
+ opts: Required<DaemonOptions>,
262
+ profileHealth: Map<string, NotificationProfileHealth>,
263
+ signal: AbortSignal,
264
+ log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
265
+ ): Promise<void> {
266
+ const profiles = await loadAgentProfiles({ homeDir: opts.homeDir });
267
+ if (profiles.length === 0) {
268
+ await log('info', 'notifications.no_profiles');
269
+ return;
270
+ }
271
+ await Promise.allSettled(profiles.map((profile) => runNotificationProfile(profile, opts.homeDir, profileHealth, signal, log)));
272
+ }
273
+
274
+ async function runNotificationProfile(
275
+ profile: CommentAgentProfile,
276
+ homeDir: string,
277
+ profileHealth: Map<string, NotificationProfileHealth>,
278
+ signal: AbortSignal,
279
+ log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
280
+ ): Promise<void> {
281
+ let reconnects = 0;
282
+ const health: NotificationProfileHealth = {
283
+ handle: profile.handle,
284
+ connected: false,
285
+ lastConnectedAt: null,
286
+ lastMessageAt: null,
287
+ lastLeaseAt: null,
288
+ lastError: null,
289
+ reconnects,
290
+ };
291
+ profileHealth.set(profile.handle, health);
292
+
293
+ while (!signal.aborted) {
294
+ try {
295
+ health.connected = true;
296
+ health.lastConnectedAt = new Date().toISOString();
297
+ health.lastError = null;
298
+ const lease = await waitForServerNotification(profile, signal);
299
+ health.lastMessageAt = new Date().toISOString();
300
+ if (lease) {
301
+ const envelope = buildQueueEnvelope({
302
+ profile: profile.handle,
303
+ baseUrl: profile.baseUrl,
304
+ claimId: lease.claim_id,
305
+ claimedAt: lease.claimed_at,
306
+ leaseExpiresAt: lease.lease_expires_at,
307
+ notification: lease.notification,
308
+ });
309
+ await writeQueueEnvelope(envelope, homeDir);
310
+ health.lastLeaseAt = new Date().toISOString();
311
+ await log('info', 'notifications.queued', { profile: profile.handle, claim_id: lease.claim_id, notification_id: lease.notification.id });
312
+ }
313
+ reconnects = 0;
314
+ health.reconnects = reconnects;
315
+ } catch (err) {
316
+ health.lastError = err instanceof Error ? err.message : String(err);
317
+ await log('warn', 'notifications.wait_failed', { profile: profile.handle, error: health.lastError });
318
+ reconnects += 1;
319
+ health.reconnects = reconnects;
320
+ await delay(Math.min(60_000, 1000 * 2 ** Math.min(reconnects, 6)), signal);
321
+ }
322
+ }
323
+ health.connected = false;
324
+ }
325
+
326
+ async function waitForServerNotification(
327
+ profile: CommentAgentProfile,
328
+ signal: AbortSignal,
329
+ ): Promise<{
330
+ claim_id: string;
331
+ claimed_at: string;
332
+ lease_expires_at: string;
333
+ notification: NotificationPayload;
334
+ } | null> {
335
+ const resp = await fetch(`${profile.baseUrl}/agents/me/notifications/wait?timeout=55&lease=600`, {
336
+ method: 'POST',
337
+ signal,
338
+ headers: {
339
+ Authorization: `Bearer ${profile.agentSecret}`,
340
+ 'Content-Type': 'application/json',
341
+ },
342
+ });
343
+ if (resp.status === 204) return null;
344
+ if (!resp.ok) throw new Error(`wait failed with ${resp.status}`);
345
+ return resp.json() as Promise<{
346
+ claim_id: string;
347
+ claimed_at: string;
348
+ lease_expires_at: string;
349
+ notification: NotificationPayload;
350
+ }>;
351
+ }
352
+
353
+ async function runLocalSweepLoop(
354
+ homeDir: string,
355
+ signal: AbortSignal,
356
+ log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
357
+ ): Promise<void> {
358
+ while (!signal.aborted) {
359
+ const swept = await sweepExpiredLocalClaims(homeDir).catch(() => 0);
360
+ if (swept > 0) await log('info', 'notifications.local_claims_swept', { swept });
361
+ await delay(30_000, signal);
362
+ }
363
+ }
364
+
365
+ function delay(ms: number, signal: AbortSignal): Promise<void> {
366
+ return new Promise((resolvePromise) => {
367
+ if (signal.aborted) {
368
+ resolvePromise();
369
+ return;
370
+ }
371
+ const timer = setTimeout(resolvePromise, ms);
372
+ signal.addEventListener('abort', () => {
373
+ clearTimeout(timer);
374
+ resolvePromise();
375
+ }, { once: true });
376
+ });
377
+ }
378
+
379
+ export async function startDetachedDaemon(args: string[] = []): Promise<{ pid: number | undefined }> {
380
+ const script = fileURLToPath(import.meta.url);
381
+ const packageRoot = resolve(dirname(script), '..');
382
+ const tsxCli = resolve(packageRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
383
+ const child = spawn(process.execPath, [tsxCli, script, 'run', ...args], {
384
+ detached: true,
385
+ stdio: 'ignore',
386
+ });
387
+ child.unref();
388
+ return { pid: child.pid };
389
+ }
390
+
391
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
392
+ if (process.argv[2] === 'run') {
393
+ runCommentDaemon().catch((err) => {
394
+ console.error(err instanceof Error ? err.message : String(err));
395
+ process.exit(1);
396
+ });
397
+ }
398
+ }