charon-hooks 0.1.6 → 0.2.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/bin/charon.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { resolve, dirname } from 'path';
4
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
5
- import { homedir } from 'os';
4
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, unlinkSync } from 'fs';
5
+ import { homedir, platform } from 'os';
6
6
  import { fileURLToPath } from 'url';
7
+ import { spawn } from 'child_process';
8
+ import YAML from 'yaml';
7
9
 
8
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
11
 
@@ -18,16 +20,29 @@ Usage:
18
20
  charon-hooks [options]
19
21
 
20
22
  Options:
21
- --port <port> Server port (default: 3000, or PORT env)
22
- --help, -h Show this help
23
- --version, -v Show version
23
+ --help, -h Show this help
24
+ --version, -v Show version
25
+ --service <command> Service management (status|start|stop|install)
26
+
27
+ Service Commands:
28
+ --service status Check if Charon is running
29
+ --service start Start Charon in background
30
+ --service stop Stop background Charon process
31
+ --service install Generate system service files
32
+
33
+ Promise Commands (for Claude Code integration):
34
+ --wait <uuid> --trigger <id> Wait for a promise to be resolved
35
+ --resolve <uuid> --description <text> Resolve a pending promise
36
+
37
+ Configuration:
38
+ Server port and other settings are configured in config/config.yaml.
39
+ Copy config/config.yaml.dist to config/config.yaml to customize.
24
40
 
25
41
  Environment:
26
- PORT Server port
27
42
  CHARON_DATA_DIR Override data directory (default: ~/.charon)
28
43
 
29
44
  Data Location:
30
- If ./config/triggers.yaml exists, uses current directory (dev mode).
45
+ If ./config/config.yaml exists, uses current directory (dev mode).
31
46
  Otherwise, uses ~/.charon/ for configuration and data.
32
47
 
33
48
  Documentation:
@@ -43,38 +58,446 @@ if (args.includes('--version') || args.includes('-v')) {
43
58
  process.exit(0);
44
59
  }
45
60
 
46
- // Determine data directory
47
- const localConfig = resolve(process.cwd(), 'config/triggers.yaml');
61
+ // Determine data directory based on config file presence
62
+ const localConfig = resolve(process.cwd(), 'config/config.yaml');
48
63
  const userDataDir = process.env.CHARON_DATA_DIR || resolve(homedir(), '.charon');
49
64
 
50
65
  let dataDir;
51
66
  if (existsSync(localConfig)) {
52
67
  dataDir = process.cwd();
53
- console.log('[charon] Using local config:', localConfig);
54
68
  } else {
55
69
  dataDir = userDataDir;
56
70
  ensureDataDir(dataDir);
57
- console.log('[charon] Using user config:', resolve(dataDir, 'config/triggers.yaml'));
58
71
  }
59
72
 
60
73
  // Set environment for the server
61
74
  process.env.CHARON_DATA_DIR = dataDir;
62
75
 
63
- // Parse port
64
- let port = process.env.PORT || '3000';
65
- const portIdx = args.indexOf('--port');
66
- if (portIdx !== -1 && args[portIdx + 1]) {
67
- port = args[portIdx + 1];
76
+ // Load configuration
77
+ const config = loadConfig(dataDir);
78
+ const port = config.server?.port || 3000;
79
+ process.env.PORT = String(port);
80
+
81
+ // PID file location
82
+ const pidFile = resolve(dataDir, 'charon.pid');
83
+
84
+ // Handle service commands
85
+ const serviceIdx = args.indexOf('--service');
86
+ if (serviceIdx !== -1) {
87
+ const command = args[serviceIdx + 1];
88
+ await handleServiceCommand(command, port, pidFile);
89
+ process.exit(0);
90
+ }
91
+
92
+ // Handle promise --wait command
93
+ const waitIdx = args.indexOf('--wait');
94
+ if (waitIdx !== -1) {
95
+ const uuid = args[waitIdx + 1];
96
+ const triggerIdx = args.indexOf('--trigger');
97
+ const triggerId = triggerIdx !== -1 ? args[triggerIdx + 1] : 'unknown';
98
+
99
+ if (!uuid) {
100
+ console.error('[charon] --wait requires a UUID');
101
+ process.exit(1);
102
+ }
103
+
104
+ await handleWaitCommand(uuid, triggerId, port);
105
+ process.exit(0);
106
+ }
107
+
108
+ // Handle promise --resolve command
109
+ const resolveIdx = args.indexOf('--resolve');
110
+ if (resolveIdx !== -1) {
111
+ const uuid = args[resolveIdx + 1];
112
+ const descIdx = args.indexOf('--description');
113
+ const description = descIdx !== -1 ? args[descIdx + 1] : '';
114
+
115
+ if (!uuid) {
116
+ console.error('[charon] --resolve requires a UUID');
117
+ process.exit(1);
118
+ }
119
+
120
+ await handleResolveCommand(uuid, description, port);
121
+ process.exit(0);
122
+ }
123
+
124
+ // Check if already running (singleton)
125
+ const isRunning = await isCharonRunning(port);
126
+ if (isRunning) {
127
+ console.log(`[charon] Already running on port ${port}`);
128
+ console.log('[charon] Use --service status for details, or --service stop to stop');
129
+ process.exit(0);
130
+ }
131
+
132
+ // Show startup info
133
+ if (existsSync(localConfig)) {
134
+ console.log('[charon] Dev mode: using local config');
135
+ } else {
136
+ console.log('[charon] Using user config:', resolve(dataDir, 'config'));
68
137
  }
69
- process.env.PORT = port;
138
+ console.log('[charon] Server port:', port);
139
+
140
+ // Write PID file
141
+ writePidFile(pidFile, process.pid);
142
+
143
+ // Cleanup PID file on exit
144
+ process.on('exit', () => cleanupPidFile(pidFile));
145
+ process.on('SIGINT', () => { cleanupPidFile(pidFile); process.exit(0); });
146
+ process.on('SIGTERM', () => { cleanupPidFile(pidFile); process.exit(0); });
70
147
 
71
148
  // Start server
72
149
  const serverPath = resolve(__dirname, '..', 'dist', 'server', 'index.js');
73
150
  await import(serverPath);
74
151
 
152
+ /**
153
+ * Handle --wait command: blocks until the promise is resolved
154
+ */
155
+ async function handleWaitCommand(uuid, triggerId, port) {
156
+ const running = await isCharonRunning(port);
157
+ if (!running) {
158
+ console.error('[charon] Charon is not running. Start it with: npx charon-hooks --service start');
159
+ process.exit(1);
160
+ }
161
+
162
+ try {
163
+ const controller = new AbortController();
164
+
165
+ // Cleanup on process exit
166
+ const cleanup = () => {
167
+ controller.abort();
168
+ process.exit(0);
169
+ };
170
+ process.on('SIGINT', cleanup);
171
+ process.on('SIGTERM', cleanup);
172
+
173
+ const response = await fetch(
174
+ `http://localhost:${port}/api/promise/${uuid}/wait?trigger=${encodeURIComponent(triggerId)}`,
175
+ { signal: controller.signal }
176
+ );
177
+
178
+ if (!response.ok) {
179
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
180
+ console.error(`[charon] Wait failed: ${error.error || response.statusText}`);
181
+ process.exit(1);
182
+ }
183
+
184
+ const result = await response.json();
185
+
186
+ if (result.status === 'resolved') {
187
+ // Output the description to stdout (for Claude to consume)
188
+ console.log(result.description);
189
+ } else {
190
+ console.error(`[charon] Promise ${result.status}: ${result.error || 'Unknown'}`);
191
+ process.exit(1);
192
+ }
193
+ } catch (err) {
194
+ if (err.name === 'AbortError') {
195
+ // Process was killed, exit gracefully
196
+ process.exit(0);
197
+ }
198
+ console.error(`[charon] Wait error: ${err.message}`);
199
+ process.exit(1);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Handle --resolve command: resolves a pending promise
205
+ */
206
+ async function handleResolveCommand(uuid, description, port) {
207
+ const running = await isCharonRunning(port);
208
+ if (!running) {
209
+ console.error('[charon] Charon is not running. Start it with: npx charon-hooks --service start');
210
+ process.exit(1);
211
+ }
212
+
213
+ try {
214
+ const response = await fetch(`http://localhost:${port}/api/promise/${uuid}/resolve`, {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({ description }),
218
+ });
219
+
220
+ if (!response.ok) {
221
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }));
222
+ console.error(`[charon] Resolve failed: ${error.error || response.statusText}`);
223
+ process.exit(1);
224
+ }
225
+
226
+ const result = await response.json();
227
+
228
+ if (result.status === 'resolved') {
229
+ console.log(`[charon] Resolved promise ${uuid}`);
230
+ } else if (result.status === 'no_waiter') {
231
+ // Silently succeed - no one was waiting
232
+ console.log(`[charon] No waiter for ${uuid} (already resolved or never registered)`);
233
+ }
234
+ } catch (err) {
235
+ console.error(`[charon] Resolve error: ${err.message}`);
236
+ process.exit(1);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Check if Charon is running on the given port
242
+ */
243
+ async function isCharonRunning(port) {
244
+ try {
245
+ const controller = new AbortController();
246
+ const timeout = setTimeout(() => controller.abort(), 2000);
247
+
248
+ const response = await fetch(`http://localhost:${port}/api/triggers`, {
249
+ signal: controller.signal
250
+ });
251
+ clearTimeout(timeout);
252
+
253
+ return response.ok;
254
+ } catch {
255
+ return false;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Handle service management commands
261
+ */
262
+ async function handleServiceCommand(command, port, pidFile) {
263
+ switch (command) {
264
+ case 'status': {
265
+ const running = await isCharonRunning(port);
266
+ if (running) {
267
+ const pid = readPidFile(pidFile);
268
+ console.log(`[charon] Running on port ${port}${pid ? ` (PID: ${pid})` : ''}`);
269
+ } else {
270
+ console.log(`[charon] Not running`);
271
+ }
272
+ break;
273
+ }
274
+
275
+ case 'start': {
276
+ const running = await isCharonRunning(port);
277
+ if (running) {
278
+ console.log(`[charon] Already running on port ${port}`);
279
+ return;
280
+ }
281
+
282
+ console.log('[charon] Starting in background...');
283
+
284
+ // Spawn detached process
285
+ const child = spawn(process.execPath, [resolve(__dirname, 'charon.js')], {
286
+ detached: true,
287
+ stdio: 'ignore',
288
+ cwd: process.cwd(),
289
+ env: { ...process.env, CHARON_DATA_DIR: dataDir }
290
+ });
291
+
292
+ child.unref();
293
+
294
+ // Wait for startup
295
+ await new Promise(r => setTimeout(r, 1000));
296
+
297
+ const nowRunning = await isCharonRunning(port);
298
+ if (nowRunning) {
299
+ console.log(`[charon] Started on port ${port} (PID: ${child.pid})`);
300
+ } else {
301
+ console.log('[charon] Failed to start. Check logs for errors.');
302
+ process.exit(1);
303
+ }
304
+ break;
305
+ }
306
+
307
+ case 'stop': {
308
+ const running = await isCharonRunning(port);
309
+ if (!running) {
310
+ console.log('[charon] Not running');
311
+ cleanupPidFile(pidFile);
312
+ return;
313
+ }
314
+
315
+ const pid = readPidFile(pidFile);
316
+ if (pid) {
317
+ try {
318
+ process.kill(pid, 'SIGTERM');
319
+ console.log(`[charon] Stopped (PID: ${pid})`);
320
+ cleanupPidFile(pidFile);
321
+ } catch (err) {
322
+ console.log(`[charon] Could not stop process: ${err.message}`);
323
+ console.log('[charon] Process may have been started by another user or system service');
324
+ }
325
+ } else {
326
+ console.log('[charon] Running but no PID file found');
327
+ console.log('[charon] Process may have been started by system service or another method');
328
+ }
329
+ break;
330
+ }
331
+
332
+ case 'install': {
333
+ await generateServiceFiles(dataDir, port);
334
+ break;
335
+ }
336
+
337
+ default:
338
+ console.log(`[charon] Unknown service command: ${command}`);
339
+ console.log('[charon] Valid commands: status, start, stop, install');
340
+ process.exit(1);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Generate system service files
346
+ */
347
+ async function generateServiceFiles(dataDir, _port) {
348
+ const os = platform();
349
+ const serviceDir = resolve(dataDir, 'service');
350
+
351
+ if (!existsSync(serviceDir)) {
352
+ mkdirSync(serviceDir, { recursive: true });
353
+ }
354
+
355
+ if (os === 'darwin') {
356
+ // macOS launchd plist
357
+ const plistPath = resolve(serviceDir, 'com.charon.hooks.plist');
358
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
359
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
360
+ <plist version="1.0">
361
+ <dict>
362
+ <key>Label</key>
363
+ <string>com.charon.hooks</string>
364
+ <key>ProgramArguments</key>
365
+ <array>
366
+ <string>${process.execPath}</string>
367
+ <string>${resolve(__dirname, 'charon.js')}</string>
368
+ </array>
369
+ <key>EnvironmentVariables</key>
370
+ <dict>
371
+ <key>CHARON_DATA_DIR</key>
372
+ <string>${dataDir}</string>
373
+ </dict>
374
+ <key>RunAtLoad</key>
375
+ <true/>
376
+ <key>KeepAlive</key>
377
+ <true/>
378
+ <key>StandardOutPath</key>
379
+ <string>${resolve(dataDir, 'charon.log')}</string>
380
+ <key>StandardErrorPath</key>
381
+ <string>${resolve(dataDir, 'charon.error.log')}</string>
382
+ </dict>
383
+ </plist>`;
384
+
385
+ writeFileSync(plistPath, plist);
386
+ console.log(`[charon] Generated launchd plist: ${plistPath}`);
387
+ console.log('');
388
+ console.log('To install as a user service:');
389
+ console.log(` cp ${plistPath} ~/Library/LaunchAgents/`);
390
+ console.log(' launchctl load ~/Library/LaunchAgents/com.charon.hooks.plist');
391
+ console.log('');
392
+ console.log('To uninstall:');
393
+ console.log(' launchctl unload ~/Library/LaunchAgents/com.charon.hooks.plist');
394
+ console.log(' rm ~/Library/LaunchAgents/com.charon.hooks.plist');
395
+
396
+ } else if (os === 'linux') {
397
+ // Linux systemd unit file
398
+ const unitPath = resolve(serviceDir, 'charon-hooks.service');
399
+ const unit = `[Unit]
400
+ Description=Charon Hooks - Task triggering service
401
+ After=network.target
402
+
403
+ [Service]
404
+ Type=simple
405
+ ExecStart=${process.execPath} ${resolve(__dirname, 'charon.js')}
406
+ Environment=CHARON_DATA_DIR=${dataDir}
407
+ Restart=on-failure
408
+ RestartSec=5
409
+
410
+ [Install]
411
+ WantedBy=default.target
412
+ `;
413
+
414
+ writeFileSync(unitPath, unit);
415
+ console.log(`[charon] Generated systemd unit file: ${unitPath}`);
416
+ console.log('');
417
+ console.log('To install as a user service:');
418
+ console.log(' mkdir -p ~/.config/systemd/user');
419
+ console.log(` cp ${unitPath} ~/.config/systemd/user/`);
420
+ console.log(' systemctl --user daemon-reload');
421
+ console.log(' systemctl --user enable charon-hooks');
422
+ console.log(' systemctl --user start charon-hooks');
423
+ console.log('');
424
+ console.log('To check status:');
425
+ console.log(' systemctl --user status charon-hooks');
426
+ console.log('');
427
+ console.log('To uninstall:');
428
+ console.log(' systemctl --user stop charon-hooks');
429
+ console.log(' systemctl --user disable charon-hooks');
430
+ console.log(' rm ~/.config/systemd/user/charon-hooks.service');
431
+
432
+ } else {
433
+ console.log(`[charon] Service installation not supported on ${os}`);
434
+ console.log('[charon] You can start Charon manually with: npx charon-hooks --service start');
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Write PID to file
440
+ */
441
+ function writePidFile(pidFile, pid) {
442
+ try {
443
+ writeFileSync(pidFile, String(pid));
444
+ } catch {
445
+ // Ignore write errors (e.g., permission issues)
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Read PID from file
451
+ */
452
+ function readPidFile(pidFile) {
453
+ try {
454
+ if (existsSync(pidFile)) {
455
+ return parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
456
+ }
457
+ } catch {
458
+ // Ignore read errors
459
+ }
460
+ return null;
461
+ }
462
+
463
+ /**
464
+ * Cleanup PID file
465
+ */
466
+ function cleanupPidFile(pidFile) {
467
+ try {
468
+ if (existsSync(pidFile)) {
469
+ unlinkSync(pidFile);
470
+ }
471
+ } catch {
472
+ // Ignore cleanup errors
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Load configuration from config/config.yaml
478
+ */
479
+ function loadConfig(dir) {
480
+ const configPath = resolve(dir, 'config/config.yaml');
481
+
482
+ if (!existsSync(configPath)) {
483
+ return { server: { port: 3000 } };
484
+ }
485
+
486
+ try {
487
+ const content = readFileSync(configPath, 'utf-8');
488
+ const parsed = YAML.parse(content);
489
+ return parsed || { server: { port: 3000 } };
490
+ } catch (error) {
491
+ console.error('[charon] Error parsing config.yaml:', error.message);
492
+ return { server: { port: 3000 } };
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Ensure data directory structure exists with default files
498
+ */
75
499
  function ensureDataDir(dir) {
76
500
  if (!existsSync(dir)) {
77
- console.log('[charon] Creating data directory:', dir);
78
501
  mkdirSync(dir, { recursive: true });
79
502
  }
80
503
 
@@ -88,16 +511,40 @@ function ensureDataDir(dir) {
88
511
  mkdirSync(sanitizersDir, { recursive: true });
89
512
  }
90
513
 
91
- const configFile = resolve(configDir, 'triggers.yaml');
514
+ // Initialize config.yaml from .dist template
515
+ const configFile = resolve(configDir, 'config.yaml');
92
516
  if (!existsSync(configFile)) {
93
- writeFileSync(
94
- configFile,
95
- `# Charon trigger configuration
517
+ const distFile = resolve(__dirname, '..', 'config', 'config.yaml.dist');
518
+ if (existsSync(distFile)) {
519
+ copyFileSync(distFile, configFile);
520
+ } else {
521
+ writeFileSync(
522
+ configFile,
523
+ `# Charon configuration
524
+ # See https://github.com/NaxYo/charon for documentation
525
+
526
+ server:
527
+ port: 3000
528
+ `
529
+ );
530
+ }
531
+ }
532
+
533
+ // Initialize triggers.yaml
534
+ const triggersFile = resolve(configDir, 'triggers.yaml');
535
+ if (!existsSync(triggersFile)) {
536
+ const distTriggersFile = resolve(__dirname, '..', 'config', 'triggers.yaml.dist');
537
+ if (existsSync(distTriggersFile)) {
538
+ copyFileSync(distTriggersFile, triggersFile);
539
+ } else {
540
+ writeFileSync(
541
+ triggersFile,
542
+ `# Charon trigger configuration
96
543
  # See https://github.com/NaxYo/charon for documentation
97
544
 
98
545
  triggers: []
99
546
  `
100
- );
101
- console.log('[charon] Created default config:', configFile);
547
+ );
548
+ }
102
549
  }
103
550
  }
@@ -0,0 +1,9 @@
1
+ # Charon configuration
2
+ # See https://github.com/NaxYo/charon for documentation
3
+ #
4
+ # Copy this file to config.yaml to customize settings.
5
+ # For production: ~/.charon/config/config.yaml
6
+ # For development: ./config/config.yaml (takes precedence)
7
+
8
+ server:
9
+ port: 3000
@@ -0,0 +1,50 @@
1
+ # Tunnel configuration (optional)
2
+ # Exposes webhooks to the internet via ngrok
3
+ # Get your authtoken at: https://dashboard.ngrok.com/get-started/your-authtoken
4
+ tunnel:
5
+ enabled: false
6
+ provider: ngrok
7
+ authtoken: your_ngrok_authtoken_here
8
+ # Optional: use a static domain (free tier includes 1)
9
+ # Get one at: https://dashboard.ngrok.com/cloud-edge/domains
10
+ # domain: your-name.ngrok-free.app
11
+ # Optional: expose the full UI via tunnel (default: false, only webhooks exposed)
12
+ # expose_ui: false
13
+
14
+ triggers:
15
+ - id: example-webhook
16
+ name: Example Webhook
17
+ type: webhook
18
+ enabled: true
19
+ template: |
20
+ Received webhook payload:
21
+
22
+ {payload}
23
+ sanitizer: passthrough
24
+ egress: console
25
+
26
+ - id: example-cron
27
+ name: Example Cron
28
+ type: cron
29
+ schedule: "0 9 * * *"
30
+ enabled: false
31
+ template: |
32
+ Daily scheduled task at {trigger_time}
33
+ egress: console
34
+ context:
35
+ source: scheduler
36
+
37
+ # Example: CLI egress with Claude
38
+ # - id: github-issues
39
+ # name: GitHub Issue Handler
40
+ # type: webhook
41
+ # enabled: true
42
+ # template: |
43
+ # Fix issue #{issue_number}: {title}
44
+ #
45
+ # {body}
46
+ # sanitizer: github-issue
47
+ # egress: cli
48
+ # context:
49
+ # cli_template: "claude \"{description}\""
50
+ # working_dir: "/home/dev/{repo}"
@@ -1,5 +1,8 @@
1
1
  // src/server/app.ts
2
- import { Hono as Hono7 } from "hono";
2
+ import { Hono as Hono8 } from "hono";
3
+ import { dirname as dirname2, resolve as resolve3 } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { existsSync as existsSync4 } from "fs";
3
6
 
4
7
  // src/server/middleware/logger.ts
5
8
  import { createMiddleware } from "hono/factory";
@@ -482,7 +485,7 @@ function compose(template, context) {
482
485
  function generateTaskId() {
483
486
  return "task_" + Math.random().toString(36).slice(2, 10);
484
487
  }
485
- async function processWebhook(db2, trigger, payload, headers) {
488
+ async function processWebhook(db2, trigger, payload, headers, promiseUuid) {
486
489
  const run = createRun(db2, {
487
490
  trigger_id: trigger.id,
488
491
  trigger_type: "webhook",
@@ -496,7 +499,7 @@ async function processWebhook(db2, trigger, payload, headers) {
496
499
  data: { payload_size: JSON.stringify(payload).length }
497
500
  });
498
501
  updateRun(db2, run.id, { status: "processing" });
499
- return processPipeline(db2, run.id, trigger, payload, headers);
502
+ return processPipeline(db2, run.id, trigger, payload, headers, promiseUuid);
500
503
  }
501
504
  async function processCron(db2, trigger) {
502
505
  const run = createRun(db2, {
@@ -517,7 +520,7 @@ async function processCron(db2, trigger) {
517
520
  };
518
521
  return processPipeline(db2, run.id, trigger, cronContext, {});
519
522
  }
520
- async function processPipeline(db2, runId, trigger, payload, headers) {
523
+ async function processPipeline(db2, runId, trigger, payload, headers, promiseUuid) {
521
524
  const startTime = Date.now();
522
525
  try {
523
526
  let context;
@@ -545,6 +548,9 @@ async function processPipeline(db2, runId, trigger, payload, headers) {
545
548
  return { run: getRun(db2, runId), task: null };
546
549
  }
547
550
  context = { ...trigger.context, ...result };
551
+ if (promiseUuid) {
552
+ context.promise_uuid = promiseUuid;
553
+ }
548
554
  updateRun(db2, runId, {
549
555
  sanitizer_result: {
550
556
  success: true,
@@ -561,6 +567,9 @@ async function processPipeline(db2, runId, trigger, payload, headers) {
561
567
  });
562
568
  } else {
563
569
  context = typeof payload === "object" && payload !== null ? { ...trigger.context, ...payload } : { ...trigger.context, payload };
570
+ if (promiseUuid) {
571
+ context.promise_uuid = promiseUuid;
572
+ }
564
573
  }
565
574
  const composerStart = Date.now();
566
575
  const description = compose(trigger.template, context);
@@ -629,6 +638,15 @@ function initScheduler(db2, triggers) {
629
638
  const task = cron.schedule(trigger.schedule, async () => {
630
639
  console.log(`[scheduler] Firing trigger: ${trigger.id}`);
631
640
  await processCron(db2, trigger);
641
+ if (trigger.one_off) {
642
+ console.log(`[scheduler] One-off trigger '${trigger.id}' completed, deleting...`);
643
+ const job = scheduledJobs.get(trigger.id);
644
+ if (job) {
645
+ job.stop();
646
+ scheduledJobs.delete(trigger.id);
647
+ }
648
+ await deleteTrigger(trigger.id);
649
+ }
632
650
  });
633
651
  scheduledJobs.set(trigger.id, task);
634
652
  }
@@ -736,7 +754,7 @@ async function startTunnel(config, port = 3e3) {
736
754
  }
737
755
  try {
738
756
  await ngrok.disconnect();
739
- await new Promise((resolve3) => setTimeout(resolve3, 1e3));
757
+ await new Promise((resolve4) => setTimeout(resolve4, 1e3));
740
758
  } catch {
741
759
  }
742
760
  listener = null;
@@ -858,6 +876,30 @@ function getTrigger(id) {
858
876
  const config = getConfig();
859
877
  return config.triggers.find((t) => t.id === id);
860
878
  }
879
+ async function deleteTrigger(id, configPath = "config/triggers.yaml") {
880
+ if (!existsSync2(configPath)) {
881
+ return false;
882
+ }
883
+ const content = readFileSync2(configPath, "utf-8");
884
+ const result = parseConfig(content);
885
+ if (!result.success) {
886
+ console.error(`[config] Cannot delete trigger: invalid config`);
887
+ return false;
888
+ }
889
+ const config = result.data;
890
+ const triggerIndex = config.triggers.findIndex((t) => t.id === id);
891
+ if (triggerIndex === -1) {
892
+ console.warn(`[config] Trigger '${id}' not found for deletion`);
893
+ return false;
894
+ }
895
+ config.triggers.splice(triggerIndex, 1);
896
+ const yaml = await import("yaml");
897
+ const newContent = yaml.stringify(config);
898
+ writeFileSync(configPath, newContent, "utf-8");
899
+ console.log(`[config] Deleted trigger '${id}'`);
900
+ cachedConfig = config;
901
+ return true;
902
+ }
861
903
  function startConfigWatcher(configPath = "config/triggers.yaml") {
862
904
  if (configWatcher && watchedConfigPath === configPath) {
863
905
  return;
@@ -960,7 +1002,7 @@ async function writeTrigger(trigger, configPath = DEFAULT_CONFIG_PATH) {
960
1002
  return { success: false, error: `Write failed: ${err instanceof Error ? err.message : String(err)}` };
961
1003
  }
962
1004
  }
963
- async function deleteTrigger(id, configPath = DEFAULT_CONFIG_PATH) {
1005
+ async function deleteTrigger2(id, configPath = DEFAULT_CONFIG_PATH) {
964
1006
  try {
965
1007
  const content = readFileSync3(configPath, "utf-8");
966
1008
  const config = parseYaml2(content) || {};
@@ -1102,7 +1144,7 @@ async function updateTriggerInternal(id, trigger, configPath) {
1102
1144
  };
1103
1145
  }
1104
1146
  if (idChanged) {
1105
- const deleteResult = await deleteTrigger(id, configPath);
1147
+ const deleteResult = await deleteTrigger2(id, configPath);
1106
1148
  if (!deleteResult.success) {
1107
1149
  return {
1108
1150
  success: false,
@@ -1136,7 +1178,7 @@ async function deleteTriggerInternal(id, configPath) {
1136
1178
  status: 404
1137
1179
  };
1138
1180
  }
1139
- const result = await deleteTrigger(id, configPath);
1181
+ const result = await deleteTrigger2(id, configPath);
1140
1182
  if (!result.success) {
1141
1183
  return {
1142
1184
  success: false,
@@ -1479,9 +1521,8 @@ async function ensureConfig2() {
1479
1521
  configLoaded2 = true;
1480
1522
  }
1481
1523
  }
1482
- webhookRoutes.post("/:id", async (c) => {
1524
+ async function handleWebhook(c, id, promiseUuid) {
1483
1525
  await ensureConfig2();
1484
- const id = c.req.param("id");
1485
1526
  const trigger = getTrigger(id);
1486
1527
  if (!trigger) {
1487
1528
  return c.json(
@@ -1507,11 +1548,20 @@ webhookRoutes.post("/:id", async (c) => {
1507
1548
  headers[key] = value;
1508
1549
  });
1509
1550
  const db2 = getDb();
1510
- const result = await processWebhook(db2, trigger, payload, headers);
1551
+ const result = await processWebhook(db2, trigger, payload, headers, promiseUuid);
1511
1552
  return c.json(
1512
- { run_id: result.run.id, status: result.run.status },
1553
+ { run_id: result.run.id, status: result.run.status, promise_uuid: promiseUuid || null },
1513
1554
  202
1514
1555
  );
1556
+ }
1557
+ webhookRoutes.post("/:id", async (c) => {
1558
+ const id = c.req.param("id");
1559
+ return handleWebhook(c, id);
1560
+ });
1561
+ webhookRoutes.post("/:id/:uuid", async (c) => {
1562
+ const id = c.req.param("id");
1563
+ const uuid = c.req.param("uuid");
1564
+ return handleWebhook(c, id, uuid);
1515
1565
  });
1516
1566
 
1517
1567
  // src/server/routes/task.ts
@@ -1560,6 +1610,80 @@ taskRoutes.post("/complete", async (c) => {
1560
1610
  }
1561
1611
  });
1562
1612
 
1613
+ // src/server/routes/promise.ts
1614
+ import { Hono as Hono7 } from "hono";
1615
+ var promiseRoutes = new Hono7();
1616
+ var pendingPromises = /* @__PURE__ */ new Map();
1617
+ promiseRoutes.get("/:uuid/wait", async (c) => {
1618
+ const uuid = c.req.param("uuid");
1619
+ const triggerId = c.req.query("trigger") || "unknown";
1620
+ if (pendingPromises.has(uuid)) {
1621
+ return c.json({ error: "Promise already registered" }, 409);
1622
+ }
1623
+ console.log(`[promise] Waiting for ${uuid} (trigger: ${triggerId})`);
1624
+ return new Promise((promiseResolve) => {
1625
+ const cleanup = () => {
1626
+ pendingPromises.delete(uuid);
1627
+ };
1628
+ pendingPromises.set(uuid, {
1629
+ resolve: (description) => {
1630
+ cleanup();
1631
+ console.log(`[promise] Resolved ${uuid}`);
1632
+ promiseResolve(c.json({ status: "resolved", description }));
1633
+ },
1634
+ reject: (error) => {
1635
+ cleanup();
1636
+ console.log(`[promise] Rejected ${uuid}: ${error.message}`);
1637
+ promiseResolve(c.json({ status: "rejected", error: error.message }, 500));
1638
+ },
1639
+ triggerId,
1640
+ createdAt: /* @__PURE__ */ new Date()
1641
+ });
1642
+ });
1643
+ });
1644
+ promiseRoutes.post("/:uuid/resolve", async (c) => {
1645
+ const uuid = c.req.param("uuid");
1646
+ const body = await c.req.json().catch(() => ({}));
1647
+ const description = body.description || "";
1648
+ const pending = pendingPromises.get(uuid);
1649
+ if (pending) {
1650
+ pending.resolve(description);
1651
+ return c.json({ status: "resolved", uuid });
1652
+ }
1653
+ console.log(`[promise] No waiter for ${uuid}, ignoring resolve`);
1654
+ return c.json({ status: "no_waiter", uuid });
1655
+ });
1656
+ promiseRoutes.get("/:uuid", (c) => {
1657
+ const uuid = c.req.param("uuid");
1658
+ const pending = pendingPromises.get(uuid);
1659
+ if (pending) {
1660
+ return c.json({
1661
+ uuid,
1662
+ status: "pending",
1663
+ triggerId: pending.triggerId,
1664
+ createdAt: pending.createdAt.toISOString()
1665
+ });
1666
+ }
1667
+ return c.json({ uuid, status: "not_found" }, 404);
1668
+ });
1669
+ promiseRoutes.get("/", (c) => {
1670
+ const promises = Array.from(pendingPromises.entries()).map(([uuid, p]) => ({
1671
+ uuid,
1672
+ triggerId: p.triggerId,
1673
+ createdAt: p.createdAt.toISOString()
1674
+ }));
1675
+ return c.json({ promises });
1676
+ });
1677
+ promiseRoutes.delete("/:uuid", (c) => {
1678
+ const uuid = c.req.param("uuid");
1679
+ const pending = pendingPromises.get(uuid);
1680
+ if (pending) {
1681
+ pending.reject(new Error("Cancelled"));
1682
+ return c.json({ status: "cancelled", uuid });
1683
+ }
1684
+ return c.json({ status: "not_found", uuid }, 404);
1685
+ });
1686
+
1563
1687
  // src/server/middleware/tunnel-proxy.ts
1564
1688
  var TUNNEL_PATTERNS = [
1565
1689
  ".ngrok-free.app",
@@ -1587,7 +1711,11 @@ async function tunnelProxyMiddleware(c, next) {
1587
1711
  }
1588
1712
 
1589
1713
  // src/server/app.ts
1590
- var app = new Hono7();
1714
+ var __dirname = dirname2(fileURLToPath(import.meta.url));
1715
+ var prodClientDir = resolve3(__dirname, "../client");
1716
+ var devClientDir = resolve3(process.cwd(), "dist/client");
1717
+ var clientDir = existsSync4(devClientDir) ? devClientDir : prodClientDir;
1718
+ var app = new Hono8();
1591
1719
  app.use("*", quietLogger);
1592
1720
  app.use("*", tunnelProxyMiddleware);
1593
1721
  app.route("/api/triggers", triggersRoutes);
@@ -1596,8 +1724,9 @@ app.route("/api/sanitizers", sanitizersRoutes);
1596
1724
  app.route("/api/tunnel", tunnelRoutes);
1597
1725
  app.route("/api/webhook", webhookRoutes);
1598
1726
  app.route("/api/task", taskRoutes);
1599
- app.use("/*", serveStatic({ root: "./dist/client" }));
1600
- app.get("*", serveStatic({ path: "./dist/client/index.html" }));
1727
+ app.route("/api/promise", promiseRoutes);
1728
+ app.use("/*", serveStatic({ root: clientDir }));
1729
+ app.get("*", serveStatic({ path: resolve3(clientDir, "index.html") }));
1601
1730
 
1602
1731
  // src/server/init.ts
1603
1732
  async function initializeServices() {
@@ -1619,7 +1748,9 @@ await initializeServices();
1619
1748
  if (typeof Bun !== "undefined") {
1620
1749
  const server = Bun.serve({
1621
1750
  fetch: app.fetch,
1622
- port: PORT
1751
+ port: PORT,
1752
+ // Disable idle timeout to support long-running promise waits
1753
+ idleTimeout: 0
1623
1754
  });
1624
1755
  console.log(`[server] Charon running at http://localhost:${server.port}`);
1625
1756
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "charon-hooks",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Autonomous task triggering service - webhooks and cron to AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
+ "config/*.dist",
11
12
  "dist/"
12
13
  ],
13
14
  "scripts": {