agentvigil 1.0.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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/commands/autostart.d.ts +3 -0
  4. package/dist/commands/autostart.d.ts.map +1 -0
  5. package/dist/commands/autostart.js +57 -0
  6. package/dist/commands/autostart.js.map +1 -0
  7. package/dist/commands/daemon.d.ts +31 -0
  8. package/dist/commands/daemon.d.ts.map +1 -0
  9. package/dist/commands/daemon.js +131 -0
  10. package/dist/commands/daemon.js.map +1 -0
  11. package/dist/commands/setup.d.ts +5 -0
  12. package/dist/commands/setup.d.ts.map +1 -0
  13. package/dist/commands/setup.js +158 -0
  14. package/dist/commands/setup.js.map +1 -0
  15. package/dist/commands/start.d.ts +2 -0
  16. package/dist/commands/start.d.ts.map +1 -0
  17. package/dist/commands/start.js +89 -0
  18. package/dist/commands/start.js.map +1 -0
  19. package/dist/commands/uninstall.d.ts +7 -0
  20. package/dist/commands/uninstall.d.ts.map +1 -0
  21. package/dist/commands/uninstall.js +13 -0
  22. package/dist/commands/uninstall.js.map +1 -0
  23. package/dist/crypto/encryption.d.ts +11 -0
  24. package/dist/crypto/encryption.d.ts.map +1 -0
  25. package/dist/crypto/encryption.js +57 -0
  26. package/dist/crypto/encryption.js.map +1 -0
  27. package/dist/hooks/hook-handler.d.ts +18 -0
  28. package/dist/hooks/hook-handler.d.ts.map +1 -0
  29. package/dist/hooks/hook-handler.js +256 -0
  30. package/dist/hooks/hook-handler.js.map +1 -0
  31. package/dist/hooks/hook-manager.d.ts +22 -0
  32. package/dist/hooks/hook-manager.d.ts.map +1 -0
  33. package/dist/hooks/hook-manager.js +133 -0
  34. package/dist/hooks/hook-manager.js.map +1 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +88 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/notifications/fcm-client.d.ts +15 -0
  40. package/dist/notifications/fcm-client.d.ts.map +1 -0
  41. package/dist/notifications/fcm-client.js +72 -0
  42. package/dist/notifications/fcm-client.js.map +1 -0
  43. package/dist/notifications/ntfy-client.d.ts +6 -0
  44. package/dist/notifications/ntfy-client.d.ts.map +1 -0
  45. package/dist/notifications/ntfy-client.js +51 -0
  46. package/dist/notifications/ntfy-client.js.map +1 -0
  47. package/dist/relay/relay-handler.d.ts +51 -0
  48. package/dist/relay/relay-handler.d.ts.map +1 -0
  49. package/dist/relay/relay-handler.js +325 -0
  50. package/dist/relay/relay-handler.js.map +1 -0
  51. package/dist/sessions/keystroke-injector.d.ts +4 -0
  52. package/dist/sessions/keystroke-injector.d.ts.map +1 -0
  53. package/dist/sessions/keystroke-injector.js +216 -0
  54. package/dist/sessions/keystroke-injector.js.map +1 -0
  55. package/dist/sessions/process-detector.d.ts +27 -0
  56. package/dist/sessions/process-detector.d.ts.map +1 -0
  57. package/dist/sessions/process-detector.js +180 -0
  58. package/dist/sessions/process-detector.js.map +1 -0
  59. package/dist/sessions/session-manager.d.ts +37 -0
  60. package/dist/sessions/session-manager.d.ts.map +1 -0
  61. package/dist/sessions/session-manager.js +90 -0
  62. package/dist/sessions/session-manager.js.map +1 -0
  63. package/dist/sessions/session-poller.d.ts +18 -0
  64. package/dist/sessions/session-poller.d.ts.map +1 -0
  65. package/dist/sessions/session-poller.js +73 -0
  66. package/dist/sessions/session-poller.js.map +1 -0
  67. package/dist/sessions/session-watcher.d.ts +20 -0
  68. package/dist/sessions/session-watcher.d.ts.map +1 -0
  69. package/dist/sessions/session-watcher.js +71 -0
  70. package/dist/sessions/session-watcher.js.map +1 -0
  71. package/dist/sessions/tmux-bridge.d.ts +11 -0
  72. package/dist/sessions/tmux-bridge.d.ts.map +1 -0
  73. package/dist/sessions/tmux-bridge.js +67 -0
  74. package/dist/sessions/tmux-bridge.js.map +1 -0
  75. package/dist/tunnel/tunnel-manager.d.ts +8 -0
  76. package/dist/tunnel/tunnel-manager.d.ts.map +1 -0
  77. package/dist/tunnel/tunnel-manager.js +57 -0
  78. package/dist/tunnel/tunnel-manager.js.map +1 -0
  79. package/dist/tunnel/websocket-server.d.ts +48 -0
  80. package/dist/tunnel/websocket-server.d.ts.map +1 -0
  81. package/dist/tunnel/websocket-server.js +173 -0
  82. package/dist/tunnel/websocket-server.js.map +1 -0
  83. package/dist/types.d.ts +37 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +2 -0
  86. package/dist/types.js.map +1 -0
  87. package/dist/utils/config.d.ts +23 -0
  88. package/dist/utils/config.d.ts.map +1 -0
  89. package/dist/utils/config.js +54 -0
  90. package/dist/utils/config.js.map +1 -0
  91. package/dist/utils/logger.d.ts +9 -0
  92. package/dist/utils/logger.d.ts.map +1 -0
  93. package/dist/utils/logger.js +11 -0
  94. package/dist/utils/logger.js.map +1 -0
  95. package/dist/utils/qr.d.ts +19 -0
  96. package/dist/utils/qr.d.ts.map +1 -0
  97. package/dist/utils/qr.js +17 -0
  98. package/dist/utils/qr.js.map +1 -0
  99. package/package.json +67 -0
package/dist/index.js ADDED
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { logger } from './utils/logger.js';
4
+ const program = new Command();
5
+ program
6
+ .name('agentvigil')
7
+ .description('Fleet watchdog for AI coding agent sessions')
8
+ .version('1.0.0');
9
+ program
10
+ .command('setup')
11
+ .description('First-time setup: register hooks and pair with mobile app')
12
+ .option('--dry-run', 'Show what would happen without making changes')
13
+ .action(async (opts) => {
14
+ const { runSetup } = await import('./commands/setup.js');
15
+ await runSetup(opts);
16
+ });
17
+ program
18
+ .command('start')
19
+ .description('Start the AgentVigil daemon')
20
+ .action(async () => {
21
+ const { runStart } = await import('./commands/start.js');
22
+ await runStart();
23
+ });
24
+ program
25
+ .command('hook <type>')
26
+ .description('Handle a Claude Code hook event (called by hooks, not by user)')
27
+ .action(async (type) => {
28
+ const { handleHook } = await import('./hooks/hook-handler.js');
29
+ await handleHook(type);
30
+ });
31
+ program
32
+ .command('status')
33
+ .description('Show all active agent sessions')
34
+ .action(async () => {
35
+ logger.info('TODO: implement status command');
36
+ });
37
+ program
38
+ .command('unpair')
39
+ .description('Revoke mobile app pairing')
40
+ .action(async () => {
41
+ logger.info('TODO: implement unpair command');
42
+ });
43
+ program
44
+ .command('logs')
45
+ .description('Tail the AgentVigil log file')
46
+ .action(async () => {
47
+ logger.info('TODO: implement logs command');
48
+ });
49
+ program
50
+ .command('uninstall')
51
+ .description('Remove AgentVigil hooks from Claude Code and Codex')
52
+ .action(async () => {
53
+ const { runUninstall } = await import('./commands/uninstall.js');
54
+ await runUninstall();
55
+ });
56
+ program
57
+ .command('install-autostart')
58
+ .description('Start AgentVigil automatically on login')
59
+ .action(async () => {
60
+ const { runInstallAutostart } = await import('./commands/autostart.js');
61
+ await runInstallAutostart();
62
+ });
63
+ program
64
+ .command('uninstall-autostart')
65
+ .description('Remove autostart')
66
+ .action(async () => {
67
+ const { runUninstallAutostart } = await import('./commands/autostart.js');
68
+ await runUninstallAutostart();
69
+ });
70
+ // `npx agentvigil` with no arguments — show a welcome message pointing at
71
+ // `setup`/`start` instead of commander's default help-and-exit-1 behavior.
72
+ program.action(() => {
73
+ console.log('');
74
+ console.log(' AgentVigil v1.0.0');
75
+ console.log(' Fleet watchdog for AI coding agent sessions');
76
+ console.log('');
77
+ console.log(' Getting started:');
78
+ console.log(' npx agentvigil setup ← run this first');
79
+ console.log(' npx agentvigil start ← then run this');
80
+ console.log('');
81
+ console.log(' Supported agents: Claude Code, Codex CLI');
82
+ console.log(' Requires: Node.js 18+, macOS');
83
+ console.log(' Docs: https://agentvigil.app');
84
+ console.log('');
85
+ program.help();
86
+ });
87
+ program.parse();
88
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import type { AgentEvent } from '../types.js';
2
+ export declare const SERVICE_ACCOUNT_PATH: string;
3
+ /**
4
+ * Sends an AgentEvent to the phone as an FCM data message, encrypted with the
5
+ * pairing shared secret exactly like a WebSocket-delivered event — the phone's
6
+ * existing `NotificationService.handleFcmMessage` decrypts and renders it the
7
+ * same way regardless of transport. High-priority data messages are delivered
8
+ * by FCM even when the app has been force-stopped (killed).
9
+ *
10
+ * Returns true if the message was handed to FCM, false if FCM isn't
11
+ * configured or the send failed — the caller should fall back to ntfy.
12
+ */
13
+ export declare function sendFcmEvent(fcmToken: string, event: AgentEvent, sharedSecret: string): Promise<boolean>;
14
+ export declare function printFcmSetupInstructions(): void;
15
+ //# sourceMappingURL=fcm-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fcm-client.d.ts","sourceRoot":"","sources":["../../src/notifications/fcm-client.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,eAAO,MAAM,oBAAoB,QAA0E,CAAC;AAuB5G;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAuB9G;AAED,wBAAgB,yBAAyB,IAAI,IAAI,CAWhD"}
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { logger } from '../utils/logger.js';
5
+ import { encrypt } from '../crypto/encryption.js';
6
+ export const SERVICE_ACCOUNT_PATH = path.join(os.homedir(), '.agentvigil', 'firebase-service-account.json');
7
+ let app;
8
+ let warnedMissing = false;
9
+ async function getMessagingApp() {
10
+ if (app)
11
+ return app;
12
+ if (!fs.existsSync(SERVICE_ACCOUNT_PATH)) {
13
+ if (!warnedMissing) {
14
+ logger.warn('Firebase service account not found — falling back to ntfy');
15
+ printFcmSetupInstructions();
16
+ warnedMissing = true;
17
+ }
18
+ return undefined;
19
+ }
20
+ const { initializeApp, cert } = await import('firebase-admin/app');
21
+ const serviceAccount = JSON.parse(fs.readFileSync(SERVICE_ACCOUNT_PATH, 'utf-8'));
22
+ app = initializeApp({ credential: cert(serviceAccount) }, 'agentvigil-fcm');
23
+ return app;
24
+ }
25
+ /**
26
+ * Sends an AgentEvent to the phone as an FCM data message, encrypted with the
27
+ * pairing shared secret exactly like a WebSocket-delivered event — the phone's
28
+ * existing `NotificationService.handleFcmMessage` decrypts and renders it the
29
+ * same way regardless of transport. High-priority data messages are delivered
30
+ * by FCM even when the app has been force-stopped (killed).
31
+ *
32
+ * Returns true if the message was handed to FCM, false if FCM isn't
33
+ * configured or the send failed — the caller should fall back to ntfy.
34
+ */
35
+ export async function sendFcmEvent(fcmToken, event, sharedSecret) {
36
+ const fcmApp = await getMessagingApp();
37
+ if (!fcmApp)
38
+ return false;
39
+ try {
40
+ const { getMessaging } = await import('firebase-admin/messaging');
41
+ await getMessaging(fcmApp).send({
42
+ token: fcmToken,
43
+ data: {
44
+ event_type: event.type,
45
+ payload: encrypt(JSON.stringify(event), sharedSecret),
46
+ },
47
+ android: {
48
+ priority: 'high',
49
+ ttl: 300_000, // 5 minutes — long enough for a permission prompt to still be relevant
50
+ },
51
+ });
52
+ logger.success(`FCM push sent: ${event.type} (${event.project_name})`);
53
+ return true;
54
+ }
55
+ catch (err) {
56
+ logger.warn('FCM push failed — falling back to ntfy', err);
57
+ return false;
58
+ }
59
+ }
60
+ export function printFcmSetupInstructions() {
61
+ logger.info('');
62
+ logger.info('To enable killed-app notifications:');
63
+ logger.info('1. Go to Firebase Console → Project Settings');
64
+ logger.info('2. Service Accounts → Generate new private key');
65
+ logger.info('3. Save the JSON file to:');
66
+ logger.info(` ${SERVICE_ACCOUNT_PATH}`);
67
+ logger.info('4. Restart AgentVigil: node dist/index.js start');
68
+ logger.info('');
69
+ logger.info('Without this step notifications only work when');
70
+ logger.info('the app is open or backgrounded (not killed).');
71
+ }
72
+ //# sourceMappingURL=fcm-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fcm-client.js","sourceRoot":"","sources":["../../src/notifications/fcm-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ export declare function sendPermissionNotification(topic: string, projectName: string, command: string, sessionId: string): Promise<void>;
2
+ export declare function sendTaskCompleteNotification(topic: string, projectName: string, duration: string): Promise<void>;
3
+ export declare function sendErrorNotification(topic: string, projectName: string, error: string): Promise<void>;
4
+ export declare function sendIdleNotification(topic: string, projectName: string, sessionId: string): Promise<void>;
5
+ export declare function sendSessionEndedNotification(topic: string, projectName: string): Promise<void>;
6
+ //# sourceMappingURL=ntfy-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ntfy-client.d.ts","sourceRoot":"","sources":["../../src/notifications/ntfy-client.ts"],"names":[],"mappings":"AAaA,wBAAsB,0BAA0B,CAC9C,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAaf;AAED,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAUf;AAED,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC,CAUf;AAED,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAWf;AAED,wBAAsB,4BAA4B,CAChD,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,CAAC,CAUf"}
@@ -0,0 +1,51 @@
1
+ import fetch from 'node-fetch';
2
+ import { logger } from '../utils/logger.js';
3
+ const NTFY_BASE = 'https://ntfy.sh';
4
+ async function publish(topic, headers, body) {
5
+ try {
6
+ await fetch(`${NTFY_BASE}/${topic}`, { method: 'POST', headers, body });
7
+ }
8
+ catch (err) {
9
+ logger.warn('ntfy push failed (offline?)', err);
10
+ }
11
+ }
12
+ export async function sendPermissionNotification(topic, projectName, command, sessionId) {
13
+ await publish(topic, {
14
+ // HTTP header values must be Latin-1 — emoji render from `Tags` instead (see NOTIFICATIONS.md).
15
+ 'Title': `[${projectName}] Permission Required`,
16
+ 'Priority': 'urgent',
17
+ 'Tags': 'warning,rotating_light',
18
+ 'Click': `agentvigil://session/${sessionId}`,
19
+ 'Actions': `http, APPROVE, https://ntfy.sh/${topic}/approve/${sessionId}; http, DENY, https://ntfy.sh/${topic}/deny/${sessionId}`,
20
+ }, command);
21
+ }
22
+ export async function sendTaskCompleteNotification(topic, projectName, duration) {
23
+ await publish(topic, {
24
+ 'Title': `[${projectName}] Task Complete`,
25
+ 'Priority': 'default',
26
+ 'Tags': 'white_check_mark',
27
+ }, `Completed in ${duration}`);
28
+ }
29
+ export async function sendErrorNotification(topic, projectName, error) {
30
+ await publish(topic, {
31
+ 'Title': `[${projectName}] Session Error`,
32
+ 'Priority': 'high',
33
+ 'Tags': 'x,red_circle',
34
+ }, error);
35
+ }
36
+ export async function sendIdleNotification(topic, projectName, sessionId) {
37
+ await publish(topic, {
38
+ 'Title': `[${projectName}] Waiting for Input`,
39
+ 'Priority': 'default',
40
+ 'Tags': 'information_source',
41
+ 'Click': `agentvigil://session/${sessionId}`,
42
+ }, 'Claude is waiting for your input');
43
+ }
44
+ export async function sendSessionEndedNotification(topic, projectName) {
45
+ await publish(topic, {
46
+ 'Title': `[${projectName}] Session closed`,
47
+ 'Priority': 'low',
48
+ 'Tags': 'white_check_mark',
49
+ }, 'Claude Code session ended');
50
+ }
51
+ //# sourceMappingURL=ntfy-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ntfy-client.js","sourceRoot":"","sources":["../../src/notifications/ntfy-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,YAAY,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C,MAAM,SAAS,GAAG,iBAAiB,CAAC;AAEpC,KAAK,UAAU,OAAO,CAAC,KAAa,EAAE,OAA+B,EAAE,IAAY;IACjF,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,GAAG,SAAS,IAAI,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1E,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,CAAC,IAAI,CAAC,6BAA6B,EAAE,GAAG,CAAC,CAAC;IAClD,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAC9C,KAAa,EACb,WAAmB,EACnB,OAAe,EACf,SAAiB;IAEjB,MAAM,OAAO,CACX,KAAK,EACL;QACE,gGAAgG;QAChG,OAAO,EAAE,IAAI,WAAW,uBAAuB;QAC/C,UAAU,EAAE,QAAQ;QACpB,MAAM,EAAE,wBAAwB;QAChC,OAAO,EAAE,wBAAwB,SAAS,EAAE;QAC5C,SAAS,EAAE,kCAAkC,KAAK,YAAY,SAAS,iCAAiC,KAAK,SAAS,SAAS,EAAE;KAClI,EACD,OAAO,CACR,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,4BAA4B,CAChD,KAAa,EACb,WAAmB,EACnB,QAAgB;IAEhB,MAAM,OAAO,CACX,KAAK,EACL;QACE,OAAO,EAAE,IAAI,WAAW,iBAAiB;QACzC,UAAU,EAAE,SAAS;QACrB,MAAM,EAAE,kBAAkB;KAC3B,EACD,gBAAgB,QAAQ,EAAE,CAC3B,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,KAAa,EACb,WAAmB,EACnB,KAAa;IAEb,MAAM,OAAO,CACX,KAAK,EACL;QACE,OAAO,EAAE,IAAI,WAAW,iBAAiB;QACzC,UAAU,EAAE,MAAM;QAClB,MAAM,EAAE,cAAc;KACvB,EACD,KAAK,CACN,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,KAAa,EACb,WAAmB,EACnB,SAAiB;IAEjB,MAAM,OAAO,CACX,KAAK,EACL;QACE,OAAO,EAAE,IAAI,WAAW,qBAAqB;QAC7C,UAAU,EAAE,SAAS;QACrB,MAAM,EAAE,oBAAoB;QAC5B,OAAO,EAAE,wBAAwB,SAAS,EAAE;KAC7C,EACD,kCAAkC,CACnC,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,4BAA4B,CAChD,KAAa,EACb,WAAmB;IAEnB,MAAM,OAAO,CACX,KAAK,EACL;QACE,OAAO,EAAE,IAAI,WAAW,kBAAkB;QAC1C,UAAU,EAAE,KAAK;QACjB,MAAM,EAAE,kBAAkB;KAC3B,EACD,2BAA2B,CAC5B,CAAC;AACJ,CAAC"}
@@ -0,0 +1,51 @@
1
+ import type { SessionUpdate } from '../sessions/session-watcher.js';
2
+ import type { AgentEvent, PhoneCommand } from '../types.js';
3
+ export interface RelayWsServer {
4
+ sendEvent(event: AgentEvent): void;
5
+ readonly isPhoneConnected: boolean;
6
+ /** The NaCl shared secret for the paired phone, or undefined if not yet paired — used to encrypt FCM payloads identically to WS events. */
7
+ getSharedSecret(): string | undefined;
8
+ }
9
+ export declare class RelayHandler {
10
+ private readonly wsServer;
11
+ private readonly ntfyTopic;
12
+ private readonly recentTaskCompleteNotifications;
13
+ constructor(wsServer: RelayWsServer, ntfyTopic: string);
14
+ handleAgentEvent(event: AgentEvent): Promise<void>;
15
+ handlePhoneCommand(command: PhoneCommand): Promise<void>;
16
+ private broadcastSessionUpdated;
17
+ /**
18
+ * Called by the transcript watcher whenever a session's JSONL file is
19
+ * appended to. Approving/denying a permission prompt directly in the Mac
20
+ * terminal fires no hook AgentVigil listens for, so this transcript write
21
+ * is the only signal that a `blocked` session has resumed. Resolve it back
22
+ * to 'working' and broadcast `session_updated` so the fleet card leaves
23
+ * PERM and the phone clears the standing permission notification — the
24
+ * same effect as the phone's own approve/deny buttons.
25
+ */
26
+ handleTranscriptActivity(update: SessionUpdate): Promise<void>;
27
+ /**
28
+ * The session-poller can create an entry for a terminal's cwd before its
29
+ * transcript file (and thus its real session_id) exists, keying it off a
30
+ * stale/different session_id. When a hook event later arrives with the
31
+ * terminal's real session_id, that would otherwise create a SECOND entry
32
+ * for the same cwd — a duplicate fleet card, and one with no `pid`, so
33
+ * approve/deny can't inject keystrokes. Merge the stale entry's pid/tmux
34
+ * info into the new id, drop the stale entry, and tell the phone to remove
35
+ * its (now-orphaned) card.
36
+ */
37
+ private reconcileDuplicateSession;
38
+ private applyToSessionStore;
39
+ private pushNtfy;
40
+ /**
41
+ * Sends `event` directly via FCM (works even when the app is killed) when a
42
+ * phone FCM token is registered and a pairing shared secret is known.
43
+ * Returns true if FCM handled it — the caller should skip the ntfy push to
44
+ * avoid a duplicate notification. Returns false (no FCM token configured,
45
+ * not yet paired, or the send failed) so the caller falls back to ntfy.
46
+ */
47
+ private tryFcm;
48
+ /** Records a notification for `sessionId` and returns whether one was already sent within the dedup window. */
49
+ private wasRecentlyNotified;
50
+ }
51
+ //# sourceMappingURL=relay-handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay-handler.d.ts","sourceRoot":"","sources":["../../src/relay/relay-handler.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAkB,YAAY,EAAE,MAAM,aAAa,CAAC;AAa5E,MAAM,WAAW,aAAa;IAC5B,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IACnC,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;IACnC,2IAA2I;IAC3I,eAAe,IAAI,MAAM,GAAG,SAAS,CAAC;CACvC;AAoBD,qBAAa,YAAY;IAIrB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAJ5B,OAAO,CAAC,QAAQ,CAAC,+BAA+B,CAA6B;gBAG1D,QAAQ,EAAE,aAAa,EACvB,SAAS,EAAE,MAAM;IAG9B,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IA8ClD,kBAAkB,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAwD9D,OAAO,CAAC,uBAAuB;IA0B/B;;;;;;;;OAQG;IACG,wBAAwB,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IA4CpE;;;;;;;;;OASG;IACH,OAAO,CAAC,yBAAyB;IAyBjC,OAAO,CAAC,mBAAmB;YAmBb,QAAQ;IAsCtB;;;;;;OAMG;YACW,MAAM;IAapB,+GAA+G;IAC/G,OAAO,CAAC,mBAAmB;CAS5B"}
@@ -0,0 +1,325 @@
1
+ import { logger } from '../utils/logger.js';
2
+ import { getConfig, saveConfig } from '../utils/config.js';
3
+ import { deleteSession, getSession, getSessionByCwd, updateSession } from '../sessions/session-manager.js';
4
+ import { approvePermission, denyPermission, sendPromptToSession } from '../sessions/keystroke-injector.js';
5
+ import { sendErrorNotification, sendIdleNotification, sendPermissionNotification, sendTaskCompleteNotification, } from '../notifications/ntfy-client.js';
6
+ import { sendFcmEvent } from '../notifications/fcm-client.js';
7
+ // heartbeat/full_sync are outbound-only summaries — they don't represent a
8
+ // change in a session's own state, so they're intentionally absent here.
9
+ const SESSION_STATE_FOR_EVENT = {
10
+ permission_prompt: 'blocked',
11
+ task_complete: 'done',
12
+ session_error: 'error',
13
+ idle_waiting: 'idle',
14
+ session_started: 'working',
15
+ session_ended: 'done',
16
+ };
17
+ /**
18
+ * Routes AgentEvents from hook-handler.ts to the session store, the phone
19
+ * (over WebSocket when connected, and always via ntfy as a safety net), and
20
+ * routes phone commands (approve/deny/send_prompt) back to tmux-bridge.
21
+ */
22
+ // A session ending fires both a `task_complete`-mapped event and (depending
23
+ // on the hook) a `session_ended`-mapped one in quick succession. Both now
24
+ // resolve to the same "Task Complete" push, so this guards against sending
25
+ // it twice for the same session within a short window.
26
+ const TASK_COMPLETE_DEDUP_WINDOW_MS = 10_000;
27
+ // Claude Code can fire Stop and a permission_prompt Notification within
28
+ // milliseconds of each other while merely pausing for approval — only that
29
+ // near-simultaneous Stop is spurious. A Stop arriving well after the prompt
30
+ // means the user actually responded (in the Mac terminal) and the turn ran
31
+ // to completion, so it must be processed to clear the blocked state.
32
+ const SPURIOUS_STOP_WINDOW_MS = 3_000;
33
+ export class RelayHandler {
34
+ wsServer;
35
+ ntfyTopic;
36
+ recentTaskCompleteNotifications = new Map();
37
+ constructor(wsServer, ntfyTopic) {
38
+ this.wsServer = wsServer;
39
+ this.ntfyTopic = ntfyTopic;
40
+ }
41
+ async handleAgentEvent(event) {
42
+ try {
43
+ const previous = getSession(event.session_id);
44
+ // Claude Code fires a spurious Stop hook (session_ended) immediately
45
+ // before and/or after a permission_prompt Notification while the agent
46
+ // is merely paused waiting for approval — not actually finished. Only
47
+ // treat it as spurious within SPURIOUS_STOP_WINDOW_MS of the prompt; a
48
+ // later Stop means the user responded (e.g. in the Mac terminal) and
49
+ // the turn genuinely completed, so let it clear the blocked state.
50
+ if (event.type === 'session_ended' &&
51
+ previous?.state === 'blocked' &&
52
+ Date.now() - previous.last_activity.getTime() < SPURIOUS_STOP_WINDOW_MS) {
53
+ logger.dim(`Ignoring spurious Stop for ${event.project_name} — session is blocked on a permission prompt`);
54
+ return;
55
+ }
56
+ this.applyToSessionStore(event);
57
+ if (this.wsServer.isPhoneConnected) {
58
+ this.wsServer.sendEvent(event);
59
+ }
60
+ // ntfy is the safety net — send it even when the phone is live on WS,
61
+ // since the socket can drop without us knowing (see NOTIFICATIONS.md).
62
+ await this.pushNtfy(event, previous);
63
+ if (event.type === 'session_ended') {
64
+ // A permission_prompt for this session may have landed while the ntfy
65
+ // push above was in flight, putting it back into 'blocked'. Don't
66
+ // delete a session that's now awaiting approval again.
67
+ if (getSession(event.session_id)?.state === 'blocked') {
68
+ logger.dim(`Skipping deleteSession for ${event.project_name} — now blocked on a permission prompt`);
69
+ return;
70
+ }
71
+ deleteSession(event.session_id);
72
+ logger.info(`Session ended: ${event.project_name} — removed from fleet`);
73
+ }
74
+ }
75
+ catch (err) {
76
+ logger.warn('Failed to relay agent event', err);
77
+ }
78
+ }
79
+ async handlePhoneCommand(command) {
80
+ try {
81
+ switch (command.type) {
82
+ case 'approve': {
83
+ logger.info(`[ApproveDeny] approve command received for session: ${command.session_id}`);
84
+ logger.info(`[ApproveDeny] session ${command.session_id} ${getSession(command.session_id) ? 'found' : 'not found'}`);
85
+ const approved = await approvePermission(command.session_id);
86
+ logger.info(`[ApproveDeny] keystroke result (approve → '1'): ${approved ? 'SUCCESS' : 'FAILED'}`);
87
+ this.broadcastSessionUpdated(command.session_id);
88
+ return;
89
+ }
90
+ case 'deny': {
91
+ logger.info(`[ApproveDeny] deny command received for session: ${command.session_id}`);
92
+ logger.info(`[ApproveDeny] session ${command.session_id} ${getSession(command.session_id) ? 'found' : 'not found'}`);
93
+ const denied = await denyPermission(command.session_id);
94
+ logger.info(`[ApproveDeny] keystroke result (deny → '3'): ${denied ? 'SUCCESS' : 'FAILED'}`);
95
+ this.broadcastSessionUpdated(command.session_id);
96
+ return;
97
+ }
98
+ case 'send_prompt':
99
+ if (command.payload) {
100
+ logger.info(`Sending prompt to session ${command.session_id}: "${command.payload}"`);
101
+ await sendPromptToSession(command.session_id, command.payload);
102
+ }
103
+ return;
104
+ case 'register_fcm_token': {
105
+ if (!command.token)
106
+ return;
107
+ const config = await getConfig();
108
+ config.fcm_token = command.token;
109
+ await saveConfig(config);
110
+ logger.success(`FCM token registered${command.device_name ? ` for ${command.device_name}` : ''}`);
111
+ return;
112
+ }
113
+ case 'heartbeat':
114
+ logger.dim('Heartbeat received from phone');
115
+ this.wsServer.sendEvent({
116
+ type: 'heartbeat',
117
+ session_id: '',
118
+ project_name: '',
119
+ cwd: '',
120
+ agent: 'claude-code',
121
+ message: 'pong',
122
+ timestamp: new Date().toISOString(),
123
+ });
124
+ return;
125
+ default:
126
+ logger.warn(`Unknown phone command type: ${command.type}`);
127
+ }
128
+ }
129
+ catch (err) {
130
+ logger.warn('Failed to relay phone command', err);
131
+ }
132
+ }
133
+ // Resolves a blocked session back to 'working' after the phone approves or
134
+ // denies its permission prompt, and pushes the new state back to the phone
135
+ // so the fleet card and session detail screen update without a reload.
136
+ broadcastSessionUpdated(sessionId) {
137
+ if (!getSession(sessionId)) {
138
+ logger.warn(`[ApproveDeny] cannot broadcast session_updated — session ${sessionId} not found`);
139
+ return;
140
+ }
141
+ const session = updateSession(sessionId, { state: 'working' });
142
+ if (!this.wsServer.isPhoneConnected) {
143
+ logger.info(`[ApproveDeny] session ${sessionId} set to working — phone not connected, skipping broadcast`);
144
+ return;
145
+ }
146
+ this.wsServer.sendEvent({
147
+ type: 'session_updated',
148
+ session_id: session.session_id,
149
+ project_name: session.project_name,
150
+ cwd: session.cwd,
151
+ agent: session.agent,
152
+ message: session.last_message ?? 'working',
153
+ timestamp: session.last_activity.toISOString(),
154
+ pid: session.pid,
155
+ });
156
+ logger.info(`[ApproveDeny] session_updated broadcast sent for ${sessionId} (state: working)`);
157
+ }
158
+ /**
159
+ * Called by the transcript watcher whenever a session's JSONL file is
160
+ * appended to. Approving/denying a permission prompt directly in the Mac
161
+ * terminal fires no hook AgentVigil listens for, so this transcript write
162
+ * is the only signal that a `blocked` session has resumed. Resolve it back
163
+ * to 'working' and broadcast `session_updated` so the fleet card leaves
164
+ * PERM and the phone clears the standing permission notification — the
165
+ * same effect as the phone's own approve/deny buttons.
166
+ */
167
+ async handleTranscriptActivity(update) {
168
+ const session = getSession(update.session_id);
169
+ if (!session)
170
+ return;
171
+ if (session.state !== 'blocked') {
172
+ updateSession(update.session_id, {
173
+ last_message: update.last_message,
174
+ last_activity: update.last_activity,
175
+ });
176
+ return;
177
+ }
178
+ const resolved = updateSession(update.session_id, {
179
+ state: 'working',
180
+ last_message: update.last_message,
181
+ last_activity: update.last_activity,
182
+ });
183
+ const resolvedEvent = {
184
+ type: 'session_updated',
185
+ session_id: resolved.session_id,
186
+ project_name: resolved.project_name,
187
+ cwd: resolved.cwd,
188
+ agent: resolved.agent,
189
+ message: resolved.last_message ?? 'working',
190
+ timestamp: resolved.last_activity.toISOString(),
191
+ pid: resolved.pid,
192
+ };
193
+ if (this.wsServer.isPhoneConnected) {
194
+ this.wsServer.sendEvent(resolvedEvent);
195
+ logger.info(`[Transcript] session_updated broadcast sent for ${update.session_id} (resolved from blocked via terminal activity)`);
196
+ return;
197
+ }
198
+ // Phone not connected (e.g. app killed) — FCM is the only way to clear
199
+ // the standing permission notification.
200
+ if (await this.tryFcm(resolvedEvent)) {
201
+ logger.info(`[Transcript] session_updated sent via FCM for ${update.session_id} (resolved from blocked via terminal activity)`);
202
+ }
203
+ else {
204
+ logger.info(`[Transcript] session ${update.session_id} resolved to working — phone not reachable, skipping broadcast`);
205
+ }
206
+ }
207
+ /**
208
+ * The session-poller can create an entry for a terminal's cwd before its
209
+ * transcript file (and thus its real session_id) exists, keying it off a
210
+ * stale/different session_id. When a hook event later arrives with the
211
+ * terminal's real session_id, that would otherwise create a SECOND entry
212
+ * for the same cwd — a duplicate fleet card, and one with no `pid`, so
213
+ * approve/deny can't inject keystrokes. Merge the stale entry's pid/tmux
214
+ * info into the new id, drop the stale entry, and tell the phone to remove
215
+ * its (now-orphaned) card.
216
+ */
217
+ reconcileDuplicateSession(event) {
218
+ if (getSession(event.session_id))
219
+ return;
220
+ const stale = getSessionByCwd(event.cwd);
221
+ if (!stale || stale.session_id === event.session_id)
222
+ return;
223
+ updateSession(event.session_id, {
224
+ pid: stale.pid,
225
+ tmux_pane_id: stale.tmux_pane_id,
226
+ });
227
+ deleteSession(stale.session_id);
228
+ if (this.wsServer.isPhoneConnected) {
229
+ this.wsServer.sendEvent({
230
+ type: 'session_ended',
231
+ session_id: stale.session_id,
232
+ project_name: stale.project_name,
233
+ cwd: stale.cwd,
234
+ agent: stale.agent,
235
+ message: 'Replaced by a newer session for this terminal',
236
+ timestamp: new Date().toISOString(),
237
+ });
238
+ }
239
+ }
240
+ applyToSessionStore(event) {
241
+ const state = SESSION_STATE_FOR_EVENT[event.type];
242
+ if (!state)
243
+ return;
244
+ this.reconcileDuplicateSession(event);
245
+ updateSession(event.session_id, {
246
+ cwd: event.cwd,
247
+ project_name: event.project_name,
248
+ agent: event.agent,
249
+ state,
250
+ last_activity: new Date(event.timestamp),
251
+ last_message: event.message,
252
+ pid: event.pid,
253
+ permission_command: event.permission_command,
254
+ tool_name: event.tool_name,
255
+ });
256
+ }
257
+ async pushNtfy(event, previous) {
258
+ switch (event.type) {
259
+ case 'permission_prompt':
260
+ if (await this.tryFcm(event))
261
+ return;
262
+ await sendPermissionNotification(this.ntfyTopic, event.project_name, event.permission_command ?? event.message, event.session_id);
263
+ return;
264
+ case 'task_complete':
265
+ case 'session_ended':
266
+ // Session-end notifications never fire more than once per session
267
+ // within the dedup window — see TASK_COMPLETE_DEDUP_WINDOW_MS.
268
+ if (this.wasRecentlyNotified(event.session_id))
269
+ return;
270
+ if (await this.tryFcm(event))
271
+ return;
272
+ await sendTaskCompleteNotification(this.ntfyTopic, event.project_name, formatDuration(previous ? Date.now() - previous.last_activity.getTime() : 0));
273
+ return;
274
+ case 'session_error':
275
+ if (await this.tryFcm(event))
276
+ return;
277
+ await sendErrorNotification(this.ntfyTopic, event.project_name, event.message);
278
+ return;
279
+ case 'idle_waiting':
280
+ if (await this.tryFcm(event))
281
+ return;
282
+ await sendIdleNotification(this.ntfyTopic, event.project_name, event.session_id);
283
+ return;
284
+ default:
285
+ // session_started / heartbeat / full_sync have no dedicated push —
286
+ // the WebSocket event is enough for those.
287
+ return;
288
+ }
289
+ }
290
+ /**
291
+ * Sends `event` directly via FCM (works even when the app is killed) when a
292
+ * phone FCM token is registered and a pairing shared secret is known.
293
+ * Returns true if FCM handled it — the caller should skip the ntfy push to
294
+ * avoid a duplicate notification. Returns false (no FCM token configured,
295
+ * not yet paired, or the send failed) so the caller falls back to ntfy.
296
+ */
297
+ async tryFcm(event) {
298
+ const sharedSecret = this.wsServer.getSharedSecret();
299
+ if (!sharedSecret)
300
+ return false;
301
+ const config = await getConfig();
302
+ if (!config.fcm_token) {
303
+ logger.warn('No FCM token — using ntfy fallback (killed state will not work)');
304
+ return false;
305
+ }
306
+ return sendFcmEvent(config.fcm_token, event, sharedSecret);
307
+ }
308
+ /** Records a notification for `sessionId` and returns whether one was already sent within the dedup window. */
309
+ wasRecentlyNotified(sessionId) {
310
+ const now = Date.now();
311
+ const last = this.recentTaskCompleteNotifications.get(sessionId);
312
+ if (last !== undefined && now - last < TASK_COMPLETE_DEDUP_WINDOW_MS) {
313
+ return true;
314
+ }
315
+ this.recentTaskCompleteNotifications.set(sessionId, now);
316
+ return false;
317
+ }
318
+ }
319
+ function formatDuration(ms) {
320
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
321
+ const minutes = Math.floor(totalSeconds / 60);
322
+ const seconds = totalSeconds % 60;
323
+ return minutes > 0 ? `${minutes}m${seconds}s` : `${seconds}s`;
324
+ }
325
+ //# sourceMappingURL=relay-handler.js.map