charon-hooks 0.1.7 → 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,7 +1,8 @@
1
1
  // src/server/app.ts
2
- import { Hono as Hono7 } from "hono";
2
+ import { Hono as Hono8 } from "hono";
3
3
  import { dirname as dirname2, resolve as resolve3 } from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { existsSync as existsSync4 } from "fs";
5
6
 
6
7
  // src/server/middleware/logger.ts
7
8
  import { createMiddleware } from "hono/factory";
@@ -484,7 +485,7 @@ function compose(template, context) {
484
485
  function generateTaskId() {
485
486
  return "task_" + Math.random().toString(36).slice(2, 10);
486
487
  }
487
- async function processWebhook(db2, trigger, payload, headers) {
488
+ async function processWebhook(db2, trigger, payload, headers, promiseUuid) {
488
489
  const run = createRun(db2, {
489
490
  trigger_id: trigger.id,
490
491
  trigger_type: "webhook",
@@ -498,7 +499,7 @@ async function processWebhook(db2, trigger, payload, headers) {
498
499
  data: { payload_size: JSON.stringify(payload).length }
499
500
  });
500
501
  updateRun(db2, run.id, { status: "processing" });
501
- return processPipeline(db2, run.id, trigger, payload, headers);
502
+ return processPipeline(db2, run.id, trigger, payload, headers, promiseUuid);
502
503
  }
503
504
  async function processCron(db2, trigger) {
504
505
  const run = createRun(db2, {
@@ -519,7 +520,7 @@ async function processCron(db2, trigger) {
519
520
  };
520
521
  return processPipeline(db2, run.id, trigger, cronContext, {});
521
522
  }
522
- async function processPipeline(db2, runId, trigger, payload, headers) {
523
+ async function processPipeline(db2, runId, trigger, payload, headers, promiseUuid) {
523
524
  const startTime = Date.now();
524
525
  try {
525
526
  let context;
@@ -547,6 +548,9 @@ async function processPipeline(db2, runId, trigger, payload, headers) {
547
548
  return { run: getRun(db2, runId), task: null };
548
549
  }
549
550
  context = { ...trigger.context, ...result };
551
+ if (promiseUuid) {
552
+ context.promise_uuid = promiseUuid;
553
+ }
550
554
  updateRun(db2, runId, {
551
555
  sanitizer_result: {
552
556
  success: true,
@@ -563,6 +567,9 @@ async function processPipeline(db2, runId, trigger, payload, headers) {
563
567
  });
564
568
  } else {
565
569
  context = typeof payload === "object" && payload !== null ? { ...trigger.context, ...payload } : { ...trigger.context, payload };
570
+ if (promiseUuid) {
571
+ context.promise_uuid = promiseUuid;
572
+ }
566
573
  }
567
574
  const composerStart = Date.now();
568
575
  const description = compose(trigger.template, context);
@@ -631,6 +638,15 @@ function initScheduler(db2, triggers) {
631
638
  const task = cron.schedule(trigger.schedule, async () => {
632
639
  console.log(`[scheduler] Firing trigger: ${trigger.id}`);
633
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
+ }
634
650
  });
635
651
  scheduledJobs.set(trigger.id, task);
636
652
  }
@@ -860,6 +876,30 @@ function getTrigger(id) {
860
876
  const config = getConfig();
861
877
  return config.triggers.find((t) => t.id === id);
862
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
+ }
863
903
  function startConfigWatcher(configPath = "config/triggers.yaml") {
864
904
  if (configWatcher && watchedConfigPath === configPath) {
865
905
  return;
@@ -962,7 +1002,7 @@ async function writeTrigger(trigger, configPath = DEFAULT_CONFIG_PATH) {
962
1002
  return { success: false, error: `Write failed: ${err instanceof Error ? err.message : String(err)}` };
963
1003
  }
964
1004
  }
965
- async function deleteTrigger(id, configPath = DEFAULT_CONFIG_PATH) {
1005
+ async function deleteTrigger2(id, configPath = DEFAULT_CONFIG_PATH) {
966
1006
  try {
967
1007
  const content = readFileSync3(configPath, "utf-8");
968
1008
  const config = parseYaml2(content) || {};
@@ -1104,7 +1144,7 @@ async function updateTriggerInternal(id, trigger, configPath) {
1104
1144
  };
1105
1145
  }
1106
1146
  if (idChanged) {
1107
- const deleteResult = await deleteTrigger(id, configPath);
1147
+ const deleteResult = await deleteTrigger2(id, configPath);
1108
1148
  if (!deleteResult.success) {
1109
1149
  return {
1110
1150
  success: false,
@@ -1138,7 +1178,7 @@ async function deleteTriggerInternal(id, configPath) {
1138
1178
  status: 404
1139
1179
  };
1140
1180
  }
1141
- const result = await deleteTrigger(id, configPath);
1181
+ const result = await deleteTrigger2(id, configPath);
1142
1182
  if (!result.success) {
1143
1183
  return {
1144
1184
  success: false,
@@ -1481,9 +1521,8 @@ async function ensureConfig2() {
1481
1521
  configLoaded2 = true;
1482
1522
  }
1483
1523
  }
1484
- webhookRoutes.post("/:id", async (c) => {
1524
+ async function handleWebhook(c, id, promiseUuid) {
1485
1525
  await ensureConfig2();
1486
- const id = c.req.param("id");
1487
1526
  const trigger = getTrigger(id);
1488
1527
  if (!trigger) {
1489
1528
  return c.json(
@@ -1509,11 +1548,20 @@ webhookRoutes.post("/:id", async (c) => {
1509
1548
  headers[key] = value;
1510
1549
  });
1511
1550
  const db2 = getDb();
1512
- const result = await processWebhook(db2, trigger, payload, headers);
1551
+ const result = await processWebhook(db2, trigger, payload, headers, promiseUuid);
1513
1552
  return c.json(
1514
- { run_id: result.run.id, status: result.run.status },
1553
+ { run_id: result.run.id, status: result.run.status, promise_uuid: promiseUuid || null },
1515
1554
  202
1516
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);
1517
1565
  });
1518
1566
 
1519
1567
  // src/server/routes/task.ts
@@ -1562,6 +1610,80 @@ taskRoutes.post("/complete", async (c) => {
1562
1610
  }
1563
1611
  });
1564
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
+
1565
1687
  // src/server/middleware/tunnel-proxy.ts
1566
1688
  var TUNNEL_PATTERNS = [
1567
1689
  ".ngrok-free.app",
@@ -1590,8 +1712,10 @@ async function tunnelProxyMiddleware(c, next) {
1590
1712
 
1591
1713
  // src/server/app.ts
1592
1714
  var __dirname = dirname2(fileURLToPath(import.meta.url));
1593
- var clientDir = resolve3(__dirname, "../client");
1594
- var app = new Hono7();
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();
1595
1719
  app.use("*", quietLogger);
1596
1720
  app.use("*", tunnelProxyMiddleware);
1597
1721
  app.route("/api/triggers", triggersRoutes);
@@ -1600,6 +1724,7 @@ app.route("/api/sanitizers", sanitizersRoutes);
1600
1724
  app.route("/api/tunnel", tunnelRoutes);
1601
1725
  app.route("/api/webhook", webhookRoutes);
1602
1726
  app.route("/api/task", taskRoutes);
1727
+ app.route("/api/promise", promiseRoutes);
1603
1728
  app.use("/*", serveStatic({ root: clientDir }));
1604
1729
  app.get("*", serveStatic({ path: resolve3(clientDir, "index.html") }));
1605
1730
 
@@ -1623,7 +1748,9 @@ await initializeServices();
1623
1748
  if (typeof Bun !== "undefined") {
1624
1749
  const server = Bun.serve({
1625
1750
  fetch: app.fetch,
1626
- port: PORT
1751
+ port: PORT,
1752
+ // Disable idle timeout to support long-running promise waits
1753
+ idleTimeout: 0
1627
1754
  });
1628
1755
  console.log(`[server] Charon running at http://localhost:${server.port}`);
1629
1756
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "charon-hooks",
3
- "version": "0.1.7",
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": {