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.
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/commands/autostart.d.ts +3 -0
- package/dist/commands/autostart.d.ts.map +1 -0
- package/dist/commands/autostart.js +57 -0
- package/dist/commands/autostart.js.map +1 -0
- package/dist/commands/daemon.d.ts +31 -0
- package/dist/commands/daemon.d.ts.map +1 -0
- package/dist/commands/daemon.js +131 -0
- package/dist/commands/daemon.js.map +1 -0
- package/dist/commands/setup.d.ts +5 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +158 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/start.d.ts +2 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +89 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/uninstall.d.ts +7 -0
- package/dist/commands/uninstall.d.ts.map +1 -0
- package/dist/commands/uninstall.js +13 -0
- package/dist/commands/uninstall.js.map +1 -0
- package/dist/crypto/encryption.d.ts +11 -0
- package/dist/crypto/encryption.d.ts.map +1 -0
- package/dist/crypto/encryption.js +57 -0
- package/dist/crypto/encryption.js.map +1 -0
- package/dist/hooks/hook-handler.d.ts +18 -0
- package/dist/hooks/hook-handler.d.ts.map +1 -0
- package/dist/hooks/hook-handler.js +256 -0
- package/dist/hooks/hook-handler.js.map +1 -0
- package/dist/hooks/hook-manager.d.ts +22 -0
- package/dist/hooks/hook-manager.d.ts.map +1 -0
- package/dist/hooks/hook-manager.js +133 -0
- package/dist/hooks/hook-manager.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/notifications/fcm-client.d.ts +15 -0
- package/dist/notifications/fcm-client.d.ts.map +1 -0
- package/dist/notifications/fcm-client.js +72 -0
- package/dist/notifications/fcm-client.js.map +1 -0
- package/dist/notifications/ntfy-client.d.ts +6 -0
- package/dist/notifications/ntfy-client.d.ts.map +1 -0
- package/dist/notifications/ntfy-client.js +51 -0
- package/dist/notifications/ntfy-client.js.map +1 -0
- package/dist/relay/relay-handler.d.ts +51 -0
- package/dist/relay/relay-handler.d.ts.map +1 -0
- package/dist/relay/relay-handler.js +325 -0
- package/dist/relay/relay-handler.js.map +1 -0
- package/dist/sessions/keystroke-injector.d.ts +4 -0
- package/dist/sessions/keystroke-injector.d.ts.map +1 -0
- package/dist/sessions/keystroke-injector.js +216 -0
- package/dist/sessions/keystroke-injector.js.map +1 -0
- package/dist/sessions/process-detector.d.ts +27 -0
- package/dist/sessions/process-detector.d.ts.map +1 -0
- package/dist/sessions/process-detector.js +180 -0
- package/dist/sessions/process-detector.js.map +1 -0
- package/dist/sessions/session-manager.d.ts +37 -0
- package/dist/sessions/session-manager.d.ts.map +1 -0
- package/dist/sessions/session-manager.js +90 -0
- package/dist/sessions/session-manager.js.map +1 -0
- package/dist/sessions/session-poller.d.ts +18 -0
- package/dist/sessions/session-poller.d.ts.map +1 -0
- package/dist/sessions/session-poller.js +73 -0
- package/dist/sessions/session-poller.js.map +1 -0
- package/dist/sessions/session-watcher.d.ts +20 -0
- package/dist/sessions/session-watcher.d.ts.map +1 -0
- package/dist/sessions/session-watcher.js +71 -0
- package/dist/sessions/session-watcher.js.map +1 -0
- package/dist/sessions/tmux-bridge.d.ts +11 -0
- package/dist/sessions/tmux-bridge.d.ts.map +1 -0
- package/dist/sessions/tmux-bridge.js +67 -0
- package/dist/sessions/tmux-bridge.js.map +1 -0
- package/dist/tunnel/tunnel-manager.d.ts +8 -0
- package/dist/tunnel/tunnel-manager.d.ts.map +1 -0
- package/dist/tunnel/tunnel-manager.js +57 -0
- package/dist/tunnel/tunnel-manager.js.map +1 -0
- package/dist/tunnel/websocket-server.d.ts +48 -0
- package/dist/tunnel/websocket-server.d.ts.map +1 -0
- package/dist/tunnel/websocket-server.js +173 -0
- package/dist/tunnel/websocket-server.js.map +1 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/config.d.ts +23 -0
- package/dist/utils/config.d.ts.map +1 -0
- package/dist/utils/config.js +54 -0
- package/dist/utils/config.js.map +1 -0
- package/dist/utils/logger.d.ts +9 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +11 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/qr.d.ts +19 -0
- package/dist/utils/qr.d.ts.map +1 -0
- package/dist/utils/qr.js +17 -0
- package/dist/utils/qr.js.map +1 -0
- 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
|